Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 85 additions & 1 deletion b2/b2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"io"
"io/ioutil"
"net/http"
"reflect"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
})
}
23 changes: 23 additions & 0 deletions b2/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions b2/baseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 35 additions & 9 deletions b2/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Loading