diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b5834..8fcbb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ 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`. + +### Changed + +- `b2_authorize_account` and the other general API calls now target the B2 Native API v4. `(*base.B2).CreateKey` and `(*b2.Bucket).CreateKey` continue to target the v3 `b2_create_key` endpoint and produce legacy single-bucket keys. ## [0.7.2] - 2025-01-23 diff --git a/b2/b2_test.go b/b2/b2_test.go index 32e33a3..5e9048c 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,71 @@ 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() is "", so we only assert that Bucket.CreateKey + // avoids the multi-bucket path, not the propagated bucket id. + 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/integration_test.go b/b2/integration_test.go index a016145..31d3e8f 100644 --- a/b2/integration_test.go +++ b/b2/integration_test.go @@ -1090,6 +1090,79 @@ func TestListBucketContentsWithKey(t *testing.T) { } } +// TestMultiBucketKeyListsAllowedBuckets exercises the v4-only feature live: +// mint a Multi-Bucket key over two buckets, authorize with it (v4 only), and +// list within each. A misparsed allowed scope would surface as a 401 here. +func TestMultiBucketKeyListsAllowedBuckets(t *testing.T) { + ctx := context.Background() + bucket1, done := startLiveTest(ctx, t) + defer done() + client := bucket1.c + + id := os.Getenv(apiID) + bucket2, err := client.NewBucket(ctx, fmt.Sprintf("%s-%s-mb-%s", id, bucketName, uniq), nil) + if err != nil { + t.Fatal(err) + } + defer func() { + iter := bucket2.List(ctx, ListHidden()) + for iter.Next() { + if err := iter.Object().Delete(ctx); err != nil { + t.Error(err) + } + } + if err := iter.Err(); err != nil && !IsNotExist(err) { + t.Error(err) + } + if err := bucket2.Delete(ctx); err != nil && !IsNotExist(err) { + t.Error(err) + } + }() + + if _, _, err := writeFile(ctx, bucket1, "a", 1e5, 1e8); err != nil { + t.Fatal(err) + } + if _, _, err := writeFile(ctx, bucket2, "b", 1e5, 1e8); err != nil { + t.Fatal(err) + } + + key, err := client.CreateKey(ctx, "multiBucketKey", + Capabilities("listBuckets", "listFiles", "readFiles"), + BucketIDs(bucket1.b.id(), bucket2.b.id())) + if err != nil { + t.Fatalf("CreateKey(BucketIDs): %v", err) + } + defer func() { + if err := key.Delete(ctx); err != nil { + t.Error(err) + } + }() + + // Authorizing with a multi-bucket key only works against v4. + mbClient, err := NewClient(ctx, key.ID(), key.Secret()) + if err != nil { + t.Fatalf("NewClient with multi-bucket key: %v", err) + } + + for _, b := range []*Bucket{bucket1, bucket2} { + ob, err := mbClient.Bucket(ctx, b.Name()) + if err != nil { + t.Fatalf("Bucket(%s) via multi-bucket key: %v", b.Name(), err) + } + iter := ob.List(ctx) + var n int + for iter.Next() { + n++ + } + if err := iter.Err(); err != nil { + t.Errorf("list %s via multi-bucket key: %v", b.Name(), err) + } + if n == 0 { + t.Errorf("list %s via multi-bucket key: got 0 objects, want >= 1", b.Name()) + } + } +} + func TestCreateDeleteKey(t *testing.T) { ctx := context.Background() bucket, done := startLiveTest(ctx, t) diff --git a/b2/key.go b/b2/key.go index 68649a6..2276820 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,35 @@ 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 scopes the key to the given bucket IDs, producing a Multi-Bucket +// Application Key. Valid only on (*Client).CreateKey, and usable only against +// B2 native API v4. +func BucketIDs(ids ...string) KeyOption { + return func(k *keyOptions) { + k.bucketIDs = append(k.bucketIDs, ids...) + } +} + +// CreateKey creates an application key for all buckets in the project, or for +// the buckets named by the BucketIDs option. The secret is only accessible on +// the returned Key. 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 +163,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..833fa13 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,18 +482,29 @@ 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 } + storageAPI := b2resp.APIInfo.StorageAPIInfo + // A restricted key's scope lives under storageApi.allowed; it's nil for + // unrestricted keys, leaving buckets and pfx empty. + var buckets []string + var pfx string + if allowed := storageAPI.Allowed; allowed != nil { + for _, b := range allowed.Buckets { + buckets = append(buckets, b.ID) + } + pfx = allowed.Prefix + } return &B2{ accountID: b2resp.AccountID, authToken: b2resp.AuthToken, - apiURI: b2resp.APIInfo.StorageAPIInfo.URI, - s3URI: b2resp.APIInfo.StorageAPIInfo.S3URI, - downloadURI: b2resp.APIInfo.StorageAPIInfo.DownloadURI, - minPartSize: b2resp.APIInfo.StorageAPIInfo.AbsMinPartSize, - bucket: b2resp.APIInfo.StorageAPIInfo.Bucket, - pfx: b2resp.APIInfo.StorageAPIInfo.Prefix, + apiURI: storageAPI.URI, + s3URI: storageAPI.S3URI, + downloadURI: storageAPI.DownloadURI, + minPartSize: storageAPI.AbsMinPartSize, + buckets: buckets, + pfx: pfx, opts: b2opts, }, nil } @@ -583,7 +594,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 +624,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 +673,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 +721,16 @@ 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 filters on at most one bucketId, so only a single-bucket + // key can be pre-filtered here; multi-bucket keys rely on B2's server-side + // scope enforcement. + 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 +738,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 +793,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 +861,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 +886,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 +904,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 +925,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 +980,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 +1043,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 +1067,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 +1106,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 +1151,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 +1190,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 +1291,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 +1324,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 +1361,11 @@ type Key struct { b2 *B2 } -// CreateKey wraps b2_create_key. +// CreateKey wraps v3 b2_create_key, producing a single-bucket key (or, with an +// empty bucketID, an unrestricted one) that v3-only clients can still use. For +// 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 +1373,30 @@ 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 v4 b2_create_key, producing a Multi-Bucket +// Application Key. Such keys are unusable by v3-only clients; for v3 +// compatibility, create one CreateKey per bucket 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 +1417,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 +1431,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..282ab06 --- /dev/null +++ b/base/base_v4_test.go @@ -0,0 +1,268 @@ +// 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 builds a v4-shaped authorizeAccount response whose apiUrl points +// back at the test server. bucketIDs/bucketNames (equal length) are zipped into +// allowed.buckets; an empty scope omits allowed, as B2 does for master keys. +func v4AuthJSON(apiURL string, bucketIDs, bucketNames []string, namePrefix string) string { + storageAPI := map[string]any{ + "absoluteMinimumPartSize": 5000000, + "apiUrl": apiURL, + "capabilities": []string{"readFiles", "writeFiles"}, + "downloadUrl": apiURL, + "storageApi": "storage", + "recommendedPartSize": 100000000, + "s3ApiUrl": apiURL, + } + if len(bucketIDs) > 0 || namePrefix != "" { + buckets := make([]map[string]any, len(bucketIDs)) + for i, id := range bucketIDs { + buckets[i] = map[string]any{"id": id, "name": bucketNames[i]} + } + storageAPI["allowed"] = map[string]any{ + "buckets": buckets, + "capabilities": []string{"readFiles", "writeFiles"}, + "namePrefix": namePrefix, + } + } + resp := map[string]any{ + "accountId": "account-id", + "authorizationToken": "auth-token", + "applicationKeyExpirationTimestamp": 0, + "apiInfo": map[string]any{ + "storageApi": storageAPI, + }, + } + 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 carry no allowed block. + 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) + } +} + +// TestAuthorizeAccountV4ReadsAllowedNesting pins the fix: v4 scope lives under +// storageApi.allowed, so top-level v3-style bucketIds/namePrefix are ignored. +func TestAuthorizeAccountV4ReadsAllowedNesting(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Scope at the (wrong) v3 top level, with no allowed block. + resp := map[string]any{ + "accountId": "account-id", + "authorizationToken": "auth-token", + "apiInfo": map[string]any{ + "storageApi": map[string]any{ + "apiUrl": srv.URL, + "downloadUrl": srv.URL, + "s3ApiUrl": srv.URL, + // v3-style fields that v4 no longer emits: + "bucketIds": []string{"buck-a"}, + "bucketNames": []string{"name-a"}, + "namePrefix": "restic/", + }, + }, + } + b, _ := json.Marshal(resp) + fmt.Fprint(w, string(b)) + })) + 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 (top-level bucketIds must be ignored in v4)", b.buckets) + } + if b.pfx != "" { + t.Errorf("pfx = %q, want empty (top-level namePrefix must be ignored in v4)", b.pfx) + } +} + +// TestAuthorizeAccountV4SingleBucketRestrictedKey checks the headline case: a +// restricted key's nested scope is surfaced so the client knows its bucket. +func TestAuthorizeAccountV4SingleBucketRestrictedKey(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, v4AuthJSON(srv.URL, []string{"buck-a"}, []string{"name-a"}, "")) + })) + 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"}; !reflect.DeepEqual(b.buckets, want) { + t.Errorf("buckets = %v, want %v", b.buckets, want) + } +} + +// createKeyFixture serves authorize_account, then captures the single +// b2_create_key request (path, method, body) for the caller to inspect. +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..c4e12b8 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,14 +32,28 @@ type ErrorMessage struct { type StorageAPIInfo struct { AbsMinPartSize int `json:"absoluteMinimumPartSize"` URI string `json:"apiUrl"` - Bucket string `json:"bucketId"` - Name string `json:"bucketName"` Capabilities []string `json:"capabilities"` DownloadURI string `json:"downloadUrl"` Type string `json:"storageApi"` - Prefix string `json:"namePrefix"` PartSize int `json:"recommendedPartSize"` S3URI string `json:"s3ApiUrl"` + // Allowed holds the key's scope; nil for unrestricted (master) keys. + Allowed *Allowed `json:"allowed"` +} + +// Allowed is the authorizing key's scope, from apiInfo.storageApi.allowed in +// the v4 b2_authorize_account response. +type Allowed struct { + Buckets []AllowedBucket `json:"buckets"` + Capabilities []string `json:"capabilities"` + Prefix string `json:"namePrefix"` +} + +// AllowedBucket is a bucket a key is restricted to. Name is empty if the bucket +// no longer exists or the key lacks listBuckets. +type AllowedBucket struct { + ID string `json:"id"` + Name string `json:"name"` } type GroupsAPIInfo struct { @@ -287,7 +302,8 @@ type ListUnfinishedLargeFilesResponse struct { Continuation string `json:"nextFileId"` } -type CreateKeyRequest struct { +// CreateKeyRequestV3 is the v3 b2_create_key body: a single bucket restriction. +type CreateKeyRequestV3 struct { AccountID string `json:"accountId"` Capabilities []string `json:"capabilities"` Name string `json:"keyName"` @@ -296,6 +312,19 @@ type CreateKeyRequest struct { Prefix string `json:"namePrefix,omitempty"` } +// CreateKeyRequestV4 is the v4 b2_create_key body: a list of bucket IDs. +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 b2_create_key/b2_list_keys/b2_delete_key response for both API +// versions: v3 returns a singular bucketId, v4 a bucketIds array. Both tags are +// present so one struct decodes either; exactly one is populated per response. type Key struct { ID string `json:"applicationKeyId"` Secret string `json:"applicationKey"` @@ -303,7 +332,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"` }