diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b5834..e3df094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing at present +### Added + +- `(*base.B2).CreateKeyMultiBucket` and the `b2.BucketIDs` `KeyOption` + for creating Multi-Bucket Application Keys via `(*b2.Client).CreateKey`. + Multi-bucket keys are created against the B2 Native API v4 + `b2_create_key` endpoint. + +### Changed + +- `b2_authorize_account` and the other general API calls now target the + B2 Native API v4. The `apiInfo.storageApi` block is parsed from the + v4 response shape: `bucketIds` and `bucketNames` arrays in place of + the singular `bucketId` and `bucketName`. This allows the client to + authenticate with Multi-Bucket Application Keys, which the v3 + endpoint does not accept. +- `(*base.B2).CreateKey` and `(*b2.Bucket).CreateKey` continue to + target the v3 `b2_create_key` endpoint and produce legacy + single-bucket keys. This preserves wire-level compatibility with + clients that have not adopted v4. ## [0.7.2] - 2025-01-23 diff --git a/b2/b2_test.go b/b2/b2_test.go index 32e33a3..9aa5297 100644 --- a/b2/b2_test.go +++ b/b2/b2_test.go @@ -22,6 +22,7 @@ import ( "io" "io/ioutil" "net/http" + "reflect" "sort" "strings" "sync" @@ -69,6 +70,11 @@ type testRoot struct { errs *errCont auths int bucketMap map[string]map[string]string + + lastKeyMethod string + lastKeyBucketID string + lastKeyBucketIDs []string + lastKeyPrefix string } func (t *testRoot) authorizeAccount(context.Context, string, string, clientOptions) error { @@ -133,7 +139,16 @@ func (t *testRoot) reupload(err error) bool { return e.reupload } -func (t *testRoot) createKey(context.Context, string, []string, time.Duration, string, string) (b2KeyInterface, error) { +func (t *testRoot) createKey(_ context.Context, _ string, _ []string, _ time.Duration, bucketID, prefix string) (b2KeyInterface, error) { + t.lastKeyMethod = "createKey" + t.lastKeyBucketID = bucketID + t.lastKeyPrefix = prefix + return nil, nil +} +func (t *testRoot) createKeyMultiBucket(_ context.Context, _ string, _ []string, _ time.Duration, bucketIDs []string, prefix string) (b2KeyInterface, error) { + t.lastKeyMethod = "createKeyMultiBucket" + t.lastKeyBucketIDs = bucketIDs + t.lastKeyPrefix = prefix return nil, nil } func (t *testRoot) listKeys(context.Context, int, string) ([]b2KeyInterface, string, error) { @@ -1454,3 +1469,72 @@ func readFile(ctx context.Context, obj *Object, sha string, chunk, concur int) e } return nil } + +func TestCreateKeyDispatch(t *testing.T) { + ctx := context.Background() + + newClient := func() (*Client, *testRoot) { + root := &testRoot{ + bucketMap: make(map[string]map[string]string), + errs: &errCont{}, + } + return &Client{backend: &beRoot{b2i: root}}, root + } + + t.Run("ClientCreateKey routes to createKey", func(t *testing.T) { + client, root := newClient() + if _, err := client.CreateKey(ctx, "kn"); err != nil { + t.Fatalf("CreateKey: %v", err) + } + if root.lastKeyMethod != "createKey" { + t.Errorf("lastKeyMethod = %q, want %q", root.lastKeyMethod, "createKey") + } + if root.lastKeyBucketID != "" { + t.Errorf("lastKeyBucketID = %q, want empty", root.lastKeyBucketID) + } + }) + + t.Run("ClientCreateKey with BucketIDs routes to createKeyMultiBucket", func(t *testing.T) { + client, root := newClient() + if _, err := client.CreateKey(ctx, "kn", BucketIDs("buck-a", "buck-b"), Prefix("p/")); err != nil { + t.Fatalf("CreateKey: %v", err) + } + if root.lastKeyMethod != "createKeyMultiBucket" { + t.Errorf("lastKeyMethod = %q, want %q", root.lastKeyMethod, "createKeyMultiBucket") + } + if got, want := root.lastKeyBucketIDs, []string{"buck-a", "buck-b"}; !reflect.DeepEqual(got, want) { + t.Errorf("lastKeyBucketIDs = %v, want %v", got, want) + } + if root.lastKeyPrefix != "p/" { + t.Errorf("lastKeyPrefix = %q, want %q", root.lastKeyPrefix, "p/") + } + }) + + t.Run("BucketCreateKey routes to createKey", func(t *testing.T) { + // testBucket.id() returns "" so we can't assert the propagated + // bucket id here; the important invariant is that Bucket.CreateKey + // never takes the multi-bucket path. + client, root := newClient() + bucket, err := client.NewBucket(ctx, "b", &BucketAttrs{Type: Private}) + if err != nil { + t.Fatalf("NewBucket: %v", err) + } + if _, err := bucket.CreateKey(ctx, "kn", Capabilities("listFiles")); err != nil { + t.Fatalf("Bucket.CreateKey: %v", err) + } + if root.lastKeyMethod != "createKey" { + t.Errorf("lastKeyMethod = %q, want %q", root.lastKeyMethod, "createKey") + } + }) + + t.Run("BucketCreateKey rejects BucketIDs", func(t *testing.T) { + client, _ := newClient() + bucket, err := client.NewBucket(ctx, "b2", &BucketAttrs{Type: Private}) + if err != nil { + t.Fatalf("NewBucket: %v", err) + } + if _, err := bucket.CreateKey(ctx, "kn", BucketIDs("x")); err == nil { + t.Errorf("Bucket.CreateKey with BucketIDs: want error, got nil") + } + }) +} diff --git a/b2/backend.go b/b2/backend.go index b67c0fe..3713962 100644 --- a/b2/backend.go +++ b/b2/backend.go @@ -36,6 +36,7 @@ type beRootInterface interface { createBucket(ctx context.Context, name, btype string, info map[string]string, rules []LifecycleRule) (beBucketInterface, error) listBuckets(context.Context, string, ...string) ([]beBucketInterface, error) createKey(context.Context, string, []string, time.Duration, string, string) (beKeyInterface, error) + createKeyMultiBucket(context.Context, string, []string, time.Duration, []string, string) (beKeyInterface, error) listKeys(context.Context, int, string) ([]beKeyInterface, string, error) } @@ -261,6 +262,28 @@ func (r *beRoot) createKey(ctx context.Context, name string, caps []string, vali return k, nil } +func (r *beRoot) createKeyMultiBucket(ctx context.Context, name string, caps []string, valid time.Duration, bucketIDs []string, prefix string) (beKeyInterface, error) { + var k *beKey + f := func() error { + g := func() error { + got, err := r.b2i.createKeyMultiBucket(ctx, name, caps, valid, bucketIDs, prefix) + if err != nil { + return err + } + k = &beKey{ + b2i: r, + k: got, + } + return nil + } + return withReauth(ctx, r, g) + } + if err := withBackoff(ctx, r, f); err != nil { + return nil, err + } + return k, nil +} + func (r *beRoot) listKeys(ctx context.Context, max int, next string) ([]beKeyInterface, string, error) { var keys []beKeyInterface var cur string diff --git a/b2/baseline.go b/b2/baseline.go index de3a10e..8414090 100644 --- a/b2/baseline.go +++ b/b2/baseline.go @@ -38,6 +38,7 @@ type b2RootInterface interface { createBucket(context.Context, string, string, map[string]string, []LifecycleRule) (b2BucketInterface, error) listBuckets(context.Context, string, ...string) ([]b2BucketInterface, error) createKey(context.Context, string, []string, time.Duration, string, string) (b2KeyInterface, error) + createKeyMultiBucket(context.Context, string, []string, time.Duration, []string, string) (b2KeyInterface, error) listKeys(context.Context, int, string) ([]b2KeyInterface, string, error) } @@ -341,6 +342,14 @@ func (b *b2Root) createKey(ctx context.Context, name string, caps []string, vali return &b2Key{k}, nil } +func (b *b2Root) createKeyMultiBucket(ctx context.Context, name string, caps []string, valid time.Duration, bucketIDs []string, prefix string) (b2KeyInterface, error) { + k, err := b.b.CreateKeyMultiBucket(ctx, name, caps, valid, bucketIDs, prefix) + if err != nil { + return nil, err + } + return &b2Key{k}, nil +} + func (b *b2Root) listKeys(ctx context.Context, max int, next string) ([]b2KeyInterface, string, error) { keys, next, err := b.b.ListKeys(ctx, max, next) if err != nil { diff --git a/b2/key.go b/b2/key.go index 68649a6..7a8b244 100644 --- a/b2/key.go +++ b/b2/key.go @@ -52,9 +52,10 @@ func (k *Key) Secret() string { return k.k.secret() } func (k *Key) ID() string { return k.k.id() } type keyOptions struct { - caps []string - prefix string - lifetime time.Duration + caps []string + prefix string + lifetime time.Duration + bucketIDs []string } // KeyOption specifies desired properties for application keys. @@ -89,18 +90,40 @@ func Prefix(prefix string) KeyOption { } } -// CreateKey creates a global application key that is valid for all buckets in -// this project. The key's secret will only be accessible on the object -// returned from this call. +// BucketIDs restricts the requested application key to the given set of +// bucket IDs. This produces a Multi-Bucket Application Key and is only +// valid on (*Client).CreateKey; bucket-scoped keys created via +// (*Bucket).CreateKey already derive their bucket ID from the bucket. +// +// Keys created with more than one bucket ID can only be used with the B2 +// native API v4. +func BucketIDs(ids ...string) KeyOption { + return func(k *keyOptions) { + k.bucketIDs = append(k.bucketIDs, ids...) + } +} + +// CreateKey creates an application key that is valid either for all buckets +// in this project, or for an explicit set of buckets when the BucketIDs +// option is supplied. The key's secret will only be accessible on the +// object returned from this call. func (c *Client) CreateKey(ctx context.Context, name string, opts ...KeyOption) (*Key, error) { var ko keyOptions for _, o := range opts { o(&ko) } - if ko.prefix != "" { - return nil, errors.New("Prefix is not a valid option for global application keys") + if ko.prefix != "" && len(ko.bucketIDs) == 0 { + return nil, errors.New("Prefix requires at least one bucket; use BucketIDs or create the key via (*Bucket).CreateKey") + } + var ( + ki beKeyInterface + err error + ) + if len(ko.bucketIDs) > 0 { + ki, err = c.backend.createKeyMultiBucket(ctx, name, ko.caps, ko.lifetime, ko.bucketIDs, ko.prefix) + } else { + ki, err = c.backend.createKey(ctx, name, ko.caps, ko.lifetime, "", ko.prefix) } - ki, err := c.backend.createKey(ctx, name, ko.caps, ko.lifetime, "", "") if err != nil { return nil, err } @@ -145,6 +168,9 @@ func (b *Bucket) CreateKey(ctx context.Context, name string, opts ...KeyOption) for _, o := range opts { o(&ko) } + if len(ko.bucketIDs) > 0 { + return nil, errors.New("BucketIDs cannot be combined with (*Bucket).CreateKey; use (*Client).CreateKey to request a multi-bucket key") + } ki, err := b.r.createKey(ctx, name, ko.caps, ko.lifetime, b.b.id(), ko.prefix) if err != nil { return nil, err diff --git a/base/base.go b/base/base.go index bfef4ae..dcfe81d 100644 --- a/base/base.go +++ b/base/base.go @@ -310,8 +310,8 @@ type B2 struct { downloadURI string minPartSize int opts *b2Options - bucket string // restricted to this bucket if present - pfx string // restricted to objects with this prefix if present + buckets []string // restricted to these buckets if non-empty + pfx string // restricted to objects with this prefix if present } // Update replaces the B2 object with a new one, in-place. @@ -482,7 +482,7 @@ func AuthorizeAccount(ctx context.Context, account, key string, opts ...AuthOpti for _, f := range opts { f(b2opts) } - if err := b2opts.makeRequest(ctx, "b2_authorize_account", "GET", b2opts.getAPIBase()+b2types.V3api+"b2_authorize_account", nil, b2resp, headers, nil); err != nil { + if err := b2opts.makeRequest(ctx, "b2_authorize_account", "GET", b2opts.getAPIBase()+b2types.V4api+"b2_authorize_account", nil, b2resp, headers, nil); err != nil { return nil, err } return &B2{ @@ -492,7 +492,7 @@ func AuthorizeAccount(ctx context.Context, account, key string, opts ...AuthOpti s3URI: b2resp.APIInfo.StorageAPIInfo.S3URI, downloadURI: b2resp.APIInfo.StorageAPIInfo.DownloadURI, minPartSize: b2resp.APIInfo.StorageAPIInfo.AbsMinPartSize, - bucket: b2resp.APIInfo.StorageAPIInfo.Bucket, + buckets: b2resp.APIInfo.StorageAPIInfo.BucketIDs, pfx: b2resp.APIInfo.StorageAPIInfo.Prefix, opts: b2opts, }, nil @@ -583,7 +583,7 @@ func (b *B2) CreateBucket(ctx context.Context, name, btype string, info map[stri headers := map[string]string{ "Authorization": b.authToken, } - if err := b.opts.makeRequest(ctx, "b2_create_bucket", "POST", b.apiURI+b2types.V3api+"b2_create_bucket", b2req, b2resp, headers, nil); err != nil { + if err := b.opts.makeRequest(ctx, "b2_create_bucket", "POST", b.apiURI+b2types.V4api+"b2_create_bucket", b2req, b2resp, headers, nil); err != nil { return nil, err } var respRules []LifecycleRule @@ -613,7 +613,7 @@ func (b *Bucket) DeleteBucket(ctx context.Context) error { headers := map[string]string{ "Authorization": b.b2.authToken, } - return b.b2.opts.makeRequest(ctx, "b2_delete_bucket", "POST", b.b2.apiURI+b2types.V3api+"b2_delete_bucket", b2req, nil, headers, nil) + return b.b2.opts.makeRequest(ctx, "b2_delete_bucket", "POST", b.b2.apiURI+b2types.V4api+"b2_delete_bucket", b2req, nil, headers, nil) } // Bucket holds B2 bucket details. @@ -662,7 +662,7 @@ func (b *Bucket) Update(ctx context.Context) (*Bucket, error) { "Authorization": b.b2.authToken, } b2resp := &b2types.UpdateBucketResponse{} - if err := b.b2.opts.makeRequest(ctx, "b2_update_bucket", "POST", b.b2.apiURI+b2types.V3api+"b2_update_bucket", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_update_bucket", "POST", b.b2.apiURI+b2types.V4api+"b2_update_bucket", b2req, b2resp, headers, nil); err != nil { return nil, err } var respRules []LifecycleRule @@ -710,9 +710,17 @@ func (b *Bucket) S3URL() string { // ListBuckets wraps b2_list_buckets. If name is non-empty, only that bucket // will be returned if it exists; else nothing will be returned. func (b *B2) ListBuckets(ctx context.Context, name string, bucketTypes ...string) ([]*Bucket, error) { + // b2_list_buckets only accepts a single bucketId filter. When the key is + // restricted to exactly one bucket we pass it explicitly; otherwise we + // omit the filter and rely on B2's server-side enforcement of the key's + // allowed bucket list. + var filterBucketID string + if len(b.buckets) == 1 { + filterBucketID = b.buckets[0] + } b2req := &b2types.ListBucketsRequest{ AccountID: b.accountID, - Bucket: b.bucket, + Bucket: filterBucketID, Name: name, BucketTypes: bucketTypes, } @@ -720,7 +728,7 @@ func (b *B2) ListBuckets(ctx context.Context, name string, bucketTypes ...string headers := map[string]string{ "Authorization": b.authToken, } - if err := b.opts.makeRequest(ctx, "b2_list_buckets", "POST", b.apiURI+b2types.V3api+"b2_list_buckets", b2req, b2resp, headers, nil); err != nil { + if err := b.opts.makeRequest(ctx, "b2_list_buckets", "POST", b.apiURI+b2types.V4api+"b2_list_buckets", b2req, b2resp, headers, nil); err != nil { return nil, err } var buckets []*Bucket @@ -775,7 +783,7 @@ func (b *Bucket) GetUploadURL(ctx context.Context) (*URL, error) { headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_get_upload_url", "POST", b.b2.apiURI+b2types.V3api+"b2_get_upload_url", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_get_upload_url", "POST", b.b2.apiURI+b2types.V4api+"b2_get_upload_url", b2req, b2resp, headers, nil); err != nil { return nil, err } return &URL{ @@ -843,7 +851,7 @@ func (f *File) DeleteFileVersion(ctx context.Context) error { headers := map[string]string{ "Authorization": f.b2.authToken, } - return f.b2.opts.makeRequest(ctx, "b2_delete_file_version", "POST", f.b2.apiURI+b2types.V3api+"b2_delete_file_version", b2req, nil, headers, nil) + return f.b2.opts.makeRequest(ctx, "b2_delete_file_version", "POST", f.b2.apiURI+b2types.V4api+"b2_delete_file_version", b2req, nil, headers, nil) } // LargeFile holds information necessary to implement B2 large file support. @@ -868,7 +876,7 @@ func (b *Bucket) StartLargeFile(ctx context.Context, name, contentType string, i headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_start_large_file", "POST", b.b2.apiURI+b2types.V3api+"b2_start_large_file", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_start_large_file", "POST", b.b2.apiURI+b2types.V4api+"b2_start_large_file", b2req, b2resp, headers, nil); err != nil { return nil, err } return &LargeFile{ @@ -886,7 +894,7 @@ func (l *LargeFile) CancelLargeFile(ctx context.Context) error { headers := map[string]string{ "Authorization": l.b2.authToken, } - return l.b2.opts.makeRequest(ctx, "b2_cancel_large_file", "POST", l.b2.apiURI+b2types.V3api+"b2_cancel_large_file", b2req, nil, headers, nil) + return l.b2.opts.makeRequest(ctx, "b2_cancel_large_file", "POST", l.b2.apiURI+b2types.V4api+"b2_cancel_large_file", b2req, nil, headers, nil) } // FilePart is a piece of a started, but not finished, large file upload. @@ -907,7 +915,7 @@ func (f *File) ListParts(ctx context.Context, next, count int) ([]*FilePart, int headers := map[string]string{ "Authorization": f.b2.authToken, } - if err := f.b2.opts.makeRequest(ctx, "b2_list_parts", "POST", f.b2.apiURI+b2types.V3api+"b2_list_parts", b2req, b2resp, headers, nil); err != nil { + if err := f.b2.opts.makeRequest(ctx, "b2_list_parts", "POST", f.b2.apiURI+b2types.V4api+"b2_list_parts", b2req, b2resp, headers, nil); err != nil { return nil, 0, err } var parts []*FilePart @@ -962,7 +970,7 @@ func (l *LargeFile) GetUploadPartURL(ctx context.Context) (*FileChunk, error) { headers := map[string]string{ "Authorization": l.b2.authToken, } - if err := l.b2.opts.makeRequest(ctx, "b2_get_upload_part_url", "POST", l.b2.apiURI+b2types.V3api+"b2_get_upload_part_url", b2req, b2resp, headers, nil); err != nil { + if err := l.b2.opts.makeRequest(ctx, "b2_get_upload_part_url", "POST", l.b2.apiURI+b2types.V4api+"b2_get_upload_part_url", b2req, b2resp, headers, nil); err != nil { return nil, err } return &FileChunk{ @@ -1025,7 +1033,7 @@ func (l *LargeFile) FinishLargeFile(ctx context.Context) (*File, error) { headers := map[string]string{ "Authorization": l.b2.authToken, } - if err := l.b2.opts.makeRequest(ctx, "b2_finish_large_file", "POST", l.b2.apiURI+b2types.V3api+"b2_finish_large_file", b2req, b2resp, headers, nil); err != nil { + if err := l.b2.opts.makeRequest(ctx, "b2_finish_large_file", "POST", l.b2.apiURI+b2types.V4api+"b2_finish_large_file", b2req, b2resp, headers, nil); err != nil { return nil, err } return &File{ @@ -1049,7 +1057,7 @@ func (b *Bucket) ListUnfinishedLargeFiles(ctx context.Context, count int, contin headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_list_unfinished_large_files", "POST", b.b2.apiURI+b2types.V3api+"b2_list_unfinished_large_files", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_list_unfinished_large_files", "POST", b.b2.apiURI+b2types.V4api+"b2_list_unfinished_large_files", b2req, b2resp, headers, nil); err != nil { return nil, "", err } cont := b2resp.Continuation @@ -1088,7 +1096,7 @@ func (b *Bucket) ListFileNames(ctx context.Context, count int, continuation, pre headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_list_file_names", "POST", b.b2.apiURI+b2types.V3api+"b2_list_file_names", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_list_file_names", "POST", b.b2.apiURI+b2types.V4api+"b2_list_file_names", b2req, b2resp, headers, nil); err != nil { return nil, "", err } cont := b2resp.Continuation @@ -1133,7 +1141,7 @@ func (b *Bucket) ListFileVersions(ctx context.Context, count int, startName, sta headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_list_file_versions", "POST", b.b2.apiURI+b2types.V3api+"b2_list_file_versions", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_list_file_versions", "POST", b.b2.apiURI+b2types.V4api+"b2_list_file_versions", b2req, b2resp, headers, nil); err != nil { return nil, "", "", err } var files []*File @@ -1172,7 +1180,7 @@ func (b *Bucket) GetDownloadAuthorization(ctx context.Context, prefix string, va headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_get_download_authorization", "POST", b.b2.apiURI+b2types.V3api+"b2_get_download_authorization", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_get_download_authorization", "POST", b.b2.apiURI+b2types.V4api+"b2_get_download_authorization", b2req, b2resp, headers, nil); err != nil { return "", err } return b2resp.Token, nil @@ -1273,7 +1281,7 @@ func (b *Bucket) HideFile(ctx context.Context, name string) (*File, error) { headers := map[string]string{ "Authorization": b.b2.authToken, } - if err := b.b2.opts.makeRequest(ctx, "b2_hide_file", "POST", b.b2.apiURI+b2types.V3api+"b2_hide_file", b2req, b2resp, headers, nil); err != nil { + if err := b.b2.opts.makeRequest(ctx, "b2_hide_file", "POST", b.b2.apiURI+b2types.V4api+"b2_hide_file", b2req, b2resp, headers, nil); err != nil { return nil, err } return &File{ @@ -1306,7 +1314,7 @@ func (f *File) GetFileInfo(ctx context.Context) (*FileInfo, error) { headers := map[string]string{ "Authorization": f.b2.authToken, } - if err := f.b2.opts.makeRequest(ctx, "b2_get_file_info", "POST", f.b2.apiURI+b2types.V3api+"b2_get_file_info", b2req, b2resp, headers, nil); err != nil { + if err := f.b2.opts.makeRequest(ctx, "b2_get_file_info", "POST", f.b2.apiURI+b2types.V4api+"b2_get_file_info", b2req, b2resp, headers, nil); err != nil { return nil, err } f.Status = b2resp.Action @@ -1343,9 +1351,14 @@ type Key struct { b2 *B2 } -// CreateKey wraps b2_create_key. +// CreateKey wraps b2_create_key on the v3 endpoint and produces a legacy +// single-bucket application key: if bucketID is non-empty the key is +// restricted to that one bucket, otherwise it is unrestricted. Keys +// created this way are compatible with clients that still speak the B2 +// native API v3. To create a Multi-Bucket Application Key, use +// CreateKeyMultiBucket. func (b *B2) CreateKey(ctx context.Context, name string, caps []string, valid time.Duration, bucketID string, prefix string) (*Key, error) { - b2req := &b2types.CreateKeyRequest{ + b2req := &b2types.CreateKeyRequestV3{ AccountID: b.accountID, Capabilities: caps, Name: name, @@ -1353,11 +1366,32 @@ func (b *B2) CreateKey(ctx context.Context, name string, caps []string, valid ti BucketID: bucketID, Prefix: prefix, } + return b.doCreateKey(ctx, b2types.V3api, b2req) +} + +// CreateKeyMultiBucket wraps b2_create_key on the v4 endpoint and produces +// a Multi-Bucket Application Key scoped to the given bucket IDs. Keys +// produced by this method cannot be used by clients that only speak the B2 +// native API v3. If backward compatibility with v3-only clients matters, +// create one key per bucket with CreateKey instead. +func (b *B2) CreateKeyMultiBucket(ctx context.Context, name string, caps []string, valid time.Duration, bucketIDs []string, prefix string) (*Key, error) { + b2req := &b2types.CreateKeyRequestV4{ + AccountID: b.accountID, + Capabilities: caps, + Name: name, + Valid: int(valid.Seconds()), + BucketIDs: bucketIDs, + Prefix: prefix, + } + return b.doCreateKey(ctx, b2types.V4api, b2req) +} + +func (b *B2) doCreateKey(ctx context.Context, apiVersion string, b2req interface{}) (*Key, error) { b2resp := &b2types.CreateKeyResponse{} headers := map[string]string{ "Authorization": b.authToken, } - if err := b.opts.makeRequest(ctx, "b2_create_key", "POST", b.apiURI+b2types.V3api+"b2_create_key", b2req, b2resp, headers, nil); err != nil { + if err := b.opts.makeRequest(ctx, "b2_create_key", "POST", b.apiURI+apiVersion+"b2_create_key", b2req, b2resp, headers, nil); err != nil { return nil, err } return &Key{ @@ -1378,7 +1412,7 @@ func (k *Key) Delete(ctx context.Context) error { headers := map[string]string{ "Authorization": k.b2.authToken, } - return k.b2.opts.makeRequest(ctx, "b2_delete_key", "POST", k.b2.apiURI+b2types.V3api+"b2_delete_key", b2req, nil, headers, nil) + return k.b2.opts.makeRequest(ctx, "b2_delete_key", "POST", k.b2.apiURI+b2types.V4api+"b2_delete_key", b2req, nil, headers, nil) } // ListKeys wraps b2_list_keys. @@ -1392,7 +1426,7 @@ func (b *B2) ListKeys(ctx context.Context, max int, next string) ([]*Key, string "Authorization": b.authToken, } b2resp := &b2types.ListKeysResponse{} - if err := b.opts.makeRequest(ctx, "b2_list_keys", "POST", b.apiURI+b2types.V3api+"b2_list_keys", b2req, b2resp, headers, nil); err != nil { + if err := b.opts.makeRequest(ctx, "b2_list_keys", "POST", b.apiURI+b2types.V4api+"b2_list_keys", b2req, b2resp, headers, nil); err != nil { return nil, "", err } var keys []*Key diff --git a/base/base_v4_test.go b/base/base_v4_test.go new file mode 100644 index 0000000..977e427 --- /dev/null +++ b/base/base_v4_test.go @@ -0,0 +1,205 @@ +// Copyright 2026, the Blazer authors +// +// 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 base + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +// v4AuthJSON returns an authorizeAccount response body in the v4 shape. The +// apiUrl points back at the test server so follow-up calls (CreateKey, etc.) +// hit the same handler. +func v4AuthJSON(apiURL string, bucketIDs, bucketNames []string, namePrefix string) string { + resp := map[string]any{ + "accountId": "account-id", + "authorizationToken": "auth-token", + "applicationKeyExpirationTimestamp": 0, + "apiInfo": map[string]any{ + "storageApi": map[string]any{ + "absoluteMinimumPartSize": 5000000, + "apiUrl": apiURL, + "bucketIds": bucketIDs, + "bucketNames": bucketNames, + "capabilities": []string{"readFiles", "writeFiles"}, + "downloadUrl": apiURL, + "storageApi": "storage", + "namePrefix": namePrefix, + "recommendedPartSize": 100000000, + "s3ApiUrl": apiURL, + }, + }, + } + b, err := json.Marshal(resp) + if err != nil { + panic(err) + } + return string(b) +} + +func TestAuthorizeAccountV4(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/b2api/v4/b2_authorize_account" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + t.Errorf("unexpected method: %s", r.Method) + } + fmt.Fprint(w, v4AuthJSON(srv.URL, []string{"buck-a", "buck-b"}, []string{"name-a", "name-b"}, "restic/")) + })) + defer srv.Close() + + b, err := AuthorizeAccount(context.Background(), "account-id", "application-key", SetAPIBase(srv.URL)) + if err != nil { + t.Fatalf("AuthorizeAccount: %v", err) + } + if want := []string{"buck-a", "buck-b"}; !reflect.DeepEqual(b.buckets, want) { + t.Errorf("buckets = %v, want %v", b.buckets, want) + } + if b.pfx != "restic/" { + t.Errorf("pfx = %q, want %q", b.pfx, "restic/") + } + if b.apiURI != srv.URL { + t.Errorf("apiURI = %q, want %q", b.apiURI, srv.URL) + } + if b.accountID != "account-id" { + t.Errorf("accountID = %q, want %q", b.accountID, "account-id") + } +} + +func TestAuthorizeAccountV4UnrestrictedKey(t *testing.T) { + // Unrestricted keys return null/empty arrays for bucketIds/bucketNames. + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, v4AuthJSON(srv.URL, nil, nil, "")) + })) + defer srv.Close() + + b, err := AuthorizeAccount(context.Background(), "account-id", "application-key", SetAPIBase(srv.URL)) + if err != nil { + t.Fatalf("AuthorizeAccount: %v", err) + } + if len(b.buckets) != 0 { + t.Errorf("buckets = %v, want empty", b.buckets) + } + if b.pfx != "" { + t.Errorf("pfx = %q, want empty", b.pfx) + } +} + +// createKeyFixture wires up a test server that first handles authorize_account +// then records and responds to a single b2_create_key request. It returns the +// authorized B2, plus pointers to captured request metadata the caller can +// inspect after making the create_key call. +type createKeyFixture struct { + b2 *B2 + srv *httptest.Server + lastPath string + lastBody map[string]any + lastMethod string +} + +func newCreateKeyFixture(t *testing.T) *createKeyFixture { + t.Helper() + f := &createKeyFixture{} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/b2api/v4/b2_authorize_account": + fmt.Fprint(w, v4AuthJSON(f.srv.URL, nil, nil, "")) + case strings.HasSuffix(r.URL.Path, "/b2_create_key"): + f.lastPath = r.URL.Path + f.lastMethod = r.Method + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + f.lastBody = map[string]any{} + if err := json.Unmarshal(body, &f.lastBody); err != nil { + t.Errorf("decode body: %v", err) + } + // Minimal Key response. + fmt.Fprint(w, `{"applicationKeyId":"k","applicationKey":"s","accountId":"a","capabilities":[],"keyName":"n","expirationTimestamp":0}`) + default: + t.Errorf("unexpected request path: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + + b, err := AuthorizeAccount(context.Background(), "account-id", "application-key", SetAPIBase(f.srv.URL)) + if err != nil { + t.Fatalf("AuthorizeAccount: %v", err) + } + f.b2 = b + return f +} + +func (f *createKeyFixture) close() { f.srv.Close() } + +func TestCreateKeyUsesV3Endpoint(t *testing.T) { + f := newCreateKeyFixture(t) + defer f.close() + + if _, err := f.b2.CreateKey(context.Background(), "keyname", []string{"readFiles"}, 0, "buck-single", "prefix/"); err != nil { + t.Fatalf("CreateKey: %v", err) + } + if want := "/b2api/v3/b2_create_key"; f.lastPath != want { + t.Errorf("path = %q, want %q", f.lastPath, want) + } + if f.lastMethod != http.MethodPost { + t.Errorf("method = %q, want POST", f.lastMethod) + } + if got, want := f.lastBody["bucketId"], "buck-single"; got != want { + t.Errorf("request body bucketId = %v, want %q", got, want) + } + if _, ok := f.lastBody["bucketIds"]; ok { + t.Errorf("request body unexpectedly contained bucketIds: %v", f.lastBody["bucketIds"]) + } +} + +func TestCreateKeyMultiBucketUsesV4Endpoint(t *testing.T) { + f := newCreateKeyFixture(t) + defer f.close() + + if _, err := f.b2.CreateKeyMultiBucket(context.Background(), "keyname", []string{"readFiles"}, 0, []string{"buck-a", "buck-b"}, "prefix/"); err != nil { + t.Fatalf("CreateKeyMultiBucket: %v", err) + } + if want := "/b2api/v4/b2_create_key"; f.lastPath != want { + t.Errorf("path = %q, want %q", f.lastPath, want) + } + if f.lastMethod != http.MethodPost { + t.Errorf("method = %q, want POST", f.lastMethod) + } + got, ok := f.lastBody["bucketIds"].([]any) + if !ok { + t.Fatalf("request body bucketIds missing or wrong type: %v", f.lastBody["bucketIds"]) + } + want := []any{"buck-a", "buck-b"} + if !reflect.DeepEqual(got, want) { + t.Errorf("request body bucketIds = %v, want %v", got, want) + } + if _, ok := f.lastBody["bucketId"]; ok { + t.Errorf("request body unexpectedly contained bucketId: %v", f.lastBody["bucketId"]) + } +} diff --git a/internal/b2types/b2types.go b/internal/b2types/b2types.go index 297098e..8b8ef50 100644 --- a/internal/b2types/b2types.go +++ b/internal/b2types/b2types.go @@ -20,6 +20,7 @@ package b2types const ( V3api = "/b2api/v3/" + V4api = "/b2api/v4/" ) type ErrorMessage struct { @@ -31,8 +32,8 @@ type ErrorMessage struct { type StorageAPIInfo struct { AbsMinPartSize int `json:"absoluteMinimumPartSize"` URI string `json:"apiUrl"` - Bucket string `json:"bucketId"` - Name string `json:"bucketName"` + BucketIDs []string `json:"bucketIds"` + BucketNames []string `json:"bucketNames"` Capabilities []string `json:"capabilities"` DownloadURI string `json:"downloadUrl"` Type string `json:"storageApi"` @@ -287,7 +288,10 @@ type ListUnfinishedLargeFilesResponse struct { Continuation string `json:"nextFileId"` } -type CreateKeyRequest struct { +// CreateKeyRequestV3 is the b2_create_key request body for the v3 endpoint, +// which accepts only a single bucket restriction and produces a legacy +// single-bucket application key. +type CreateKeyRequestV3 struct { AccountID string `json:"accountId"` Capabilities []string `json:"capabilities"` Name string `json:"keyName"` @@ -296,6 +300,23 @@ type CreateKeyRequest struct { Prefix string `json:"namePrefix,omitempty"` } +// CreateKeyRequestV4 is the b2_create_key request body for the v4 endpoint, +// which takes a list of bucket IDs and produces a Multi-Bucket Application +// Key. +type CreateKeyRequestV4 struct { + AccountID string `json:"accountId"` + Capabilities []string `json:"capabilities"` + Name string `json:"keyName"` + Valid int `json:"validDurationInSeconds,omitempty"` + BucketIDs []string `json:"bucketIds,omitempty"` + Prefix string `json:"namePrefix,omitempty"` +} + +// Key is the response body for b2_create_key, b2_list_keys, and +// b2_delete_key across both API versions. The v3 endpoint returns a +// singular bucketId; v4 returns a bucketIds array for Multi-Bucket +// Application Keys. Both tagged fields are present so this one struct +// can decode either shape; exactly one will be populated per response. type Key struct { ID string `json:"applicationKeyId"` Secret string `json:"applicationKey"` @@ -303,7 +324,8 @@ type Key struct { Capabilities []string `json:"capabilities"` Name string `json:"keyName"` Expires int64 `json:"expirationTimestamp"` - BucketID string `json:"bucketId"` + BucketID string `json:"bucketId,omitempty"` + BucketIDs []string `json:"bucketIds,omitempty"` Prefix string `json:"namePrefix"` }