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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 84 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,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")
}
})
}
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
73 changes: 73 additions & 0 deletions b2/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 30 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,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
}
Expand Down Expand Up @@ -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
Expand Down
Loading