Skip to content

Commit 922de46

Browse files
Merge pull request Backblaze#1094 from reef-technologies/multi-bucket-keys
Add multi-bucket key support
2 parents 8ad09cd + c4667c6 commit 922de46

File tree

5 files changed

+227
-18
lines changed

5 files changed

+227
-18
lines changed

b2/_internal/console_tool.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,7 +1706,8 @@ class KeyCreateBase(Command):
17061706
specified, the key will not expire.
17071707
17081708
The ``bucket`` is the name of a bucket in the account. When specified, the key
1709-
will only allow access to that bucket.
1709+
will only allow access to that bucket. You can specify multiple buckets by repeating
1710+
the option, e.g. ``--bucket bucket1 --bucket bucket2``
17101711
17111712
The ``namePrefix`` restricts file access to files whose names start with the prefix.
17121713
@@ -1720,7 +1721,7 @@ class KeyCreateBase(Command):
17201721

17211722
@classmethod
17221723
def _setup_parser(cls, parser):
1723-
parser.add_argument('--bucket')
1724+
parser.add_argument('--bucket', action='append')
17241725
add_normalized_argument(parser, '--name-prefix')
17251726
parser.add_argument('--duration', type=int)
17261727
parser.add_argument('keyName')
@@ -1731,11 +1732,21 @@ def _setup_parser(cls, parser):
17311732
super()._setup_parser(parser)
17321733

17331734
def _run(self, args):
1734-
# Translate the bucket name into a bucketId
1735-
if args.bucket is None:
1736-
bucket_id_or_none = None
1735+
# Translate a list of bucket names into a list of bucketIds
1736+
if not args.bucket:
1737+
bucket_ids_or_none = None
1738+
elif len(args.bucket) == 1:
1739+
bucket_ids_or_none = [self.api.get_bucket_by_name(args.bucket[0]).id_]
17371740
else:
1738-
bucket_id_or_none = self.api.get_bucket_by_name(args.bucket).id_
1741+
names_seeking = set(args.bucket)
1742+
bucket_ids_or_none = []
1743+
for bucket in self.api.list_buckets(use_cache=True):
1744+
if bucket.name in names_seeking:
1745+
names_seeking.remove(bucket.name)
1746+
bucket_ids_or_none.append(bucket.id_)
1747+
1748+
if names_seeking:
1749+
raise NonExistentBucket('; '.join(names_seeking))
17391750

17401751
if args.all_capabilities:
17411752
current_key_caps = set(self.api.account_info.get_allowed()['capabilities'])
@@ -1747,13 +1758,11 @@ def _run(self, args):
17471758
set(ALL_CAPABILITIES) - preview_feature_caps | current_key_caps
17481759
)
17491760

1750-
buckets_ids = [bucket_id_or_none] if bucket_id_or_none else None
1751-
17521761
application_key = self.api.create_key(
17531762
capabilities=args.capabilities,
17541763
key_name=args.keyName,
17551764
valid_duration_seconds=args.duration,
1756-
bucket_ids=buckets_ids,
1765+
bucket_ids=bucket_ids_or_none,
17571766
name_prefix=args.name_prefix,
17581767
)
17591768

@@ -2318,7 +2327,7 @@ class KeyListBase(Command):
23182327
23192328
- ID of the application key
23202329
- Name of the application key
2321-
- Name of the bucket the key is restricted to, or ``-`` for no restriction
2330+
- Name of the bucket(s) the key is restricted to, or ``-`` for no restriction
23222331
- Date of expiration, or ``-``
23232332
- Time of expiration, or ``-``
23242333
- File name prefix, in single quotes
@@ -2352,35 +2361,35 @@ def _run(self, args):
23522361

23532362
def print_key(self, key: ApplicationKey, is_long_format: bool):
23542363
if is_long_format:
2355-
format_str = "{keyId} {keyName:20s} {bucketName:20s} {dateStr:10s} {timeStr:8s} '{namePrefix}' {capabilities}"
2364+
format_str = "{keyId} {keyName:20s} {bucketNames:20s} {dateStr:10s} {timeStr:8s} '{namePrefix}' {capabilities}"
23562365
else:
23572366
format_str = '{keyId} {keyName:20s}'
23582367
timestamp_or_none = apply_or_none(int, key.expiration_timestamp_millis)
23592368
(date_str, time_str) = self.timestamp_display(timestamp_or_none)
23602369

2361-
bucket_id = key.bucket_ids[0] if key.bucket_ids else None
2362-
23632370
key_str = format_str.format(
23642371
keyId=key.id_,
23652372
keyName=key.key_name,
2366-
bucketName=self.bucket_display_name(bucket_id),
2373+
bucketNames=self.bucket_display_names(key.bucket_ids),
23672374
namePrefix=(key.name_prefix or ''),
23682375
capabilities=','.join(key.capabilities),
23692376
dateStr=date_str,
23702377
timeStr=time_str,
23712378
)
23722379
self._print(key_str)
23732380

2374-
def bucket_display_name(self, bucket_id):
2375-
# Special case for no bucket ID
2376-
if bucket_id is None:
2381+
def bucket_display_names(self, bucket_ids):
2382+
# Special case for no bucket IDs
2383+
if not bucket_ids:
23772384
return '-'
23782385

23792386
# Make sure we have the map
23802387
if self.bucket_id_to_bucket_name is None:
23812388
self.bucket_id_to_bucket_name = dict((b.id_, b.name) for b in self.api.list_buckets())
23822389

2383-
return self.bucket_id_to_bucket_name.get(bucket_id, 'id=' + bucket_id)
2390+
display_names = [self.bucket_id_to_bucket_name.get(id_, f'id={id_}') for id_ in bucket_ids]
2391+
2392+
return ', '.join(display_names)
23842393

23852394
def timestamp_display(self, timestamp_or_none):
23862395
"""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support multi-bucket keys in `key list` subcommand.

changelog.d/1083.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add multi-bucket keys support to the `key create` subcommand.

test/integration/test_b2_command_line.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,45 @@ def test_key_restrictions(b2_tool, bucket_name, sample_file, bucket_factory, b2_
782782
)
783783

784784

785+
def test_multi_bucket_key_restrictions(b2_tool, bucket_factory):
786+
bucket_a = bucket_factory()
787+
bucket_b = bucket_factory()
788+
bucket_c = bucket_factory()
789+
790+
key_name = 'clt-testKey-01' + random_hex(6)
791+
792+
created_key_stdout = b2_tool.should_succeed(
793+
[
794+
'key',
795+
'create',
796+
'--bucket',
797+
bucket_a.name,
798+
'--bucket',
799+
bucket_b.name,
800+
key_name,
801+
'listFiles,listBuckets,readFiles',
802+
]
803+
)
804+
805+
mb_key_id, mb_key = created_key_stdout.split()
806+
807+
b2_tool.should_succeed(
808+
['account', 'authorize', '--environment', b2_tool.realm, mb_key_id, mb_key],
809+
)
810+
811+
b2_tool.should_succeed(
812+
['bucket', 'get', bucket_a.name],
813+
)
814+
b2_tool.should_succeed(
815+
['bucket', 'get', bucket_b.name],
816+
)
817+
818+
failed_bucket_err = rf"ERROR: Application key is restricted to buckets: \['{bucket_a.name}', '{bucket_b.name}'\]"
819+
b2_tool.should_fail(['bucket', 'get', bucket_c.name], failed_bucket_err)
820+
821+
b2_tool.should_succeed(['key', 'delete', mb_key_id])
822+
823+
785824
def test_delete_bucket(b2_tool, bucket_name):
786825
b2_tool.should_succeed(['bucket', 'delete', bucket_name])
787826
b2_tool.should_fail(

test/unit/test_console_tool.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,62 @@ def test_create_bucket_key_and_authorize_with_it(self):
581581
0,
582582
)
583583

584+
def test_create_multi_bucket_key_and_authorize_with_it(self):
585+
# Start with authorizing with the master key
586+
self._authorize_account()
587+
588+
# Make two buckets
589+
self._run_command(['bucket', 'create', 'my-bucket-0', 'allPrivate'], 'bucket_0\n', '', 0)
590+
self._run_command(['bucket', 'create', 'my-bucket-1', 'allPrivate'], 'bucket_1\n', '', 0)
591+
592+
# Create a key restricted to those buckets
593+
self._run_command(
594+
[
595+
'key',
596+
'create',
597+
'--bucket',
598+
'my-bucket-0',
599+
'--bucket',
600+
'my-bucket-1',
601+
'key1',
602+
'listKeys,listBuckets',
603+
],
604+
'appKeyId0 appKey0\n',
605+
'',
606+
0,
607+
)
608+
609+
# test deprecated command
610+
self._run_command(
611+
[
612+
'create-key',
613+
'--bucket',
614+
'my-bucket-0',
615+
'--bucket',
616+
'my-bucket-1',
617+
'key2',
618+
'listKeys,listBuckets',
619+
],
620+
'appKeyId1 appKey1\n',
621+
'WARNING: `create-key` command is deprecated. Use `key create` instead.\n',
622+
0,
623+
)
624+
625+
# Authorize with the key
626+
self._run_command(
627+
['account', 'authorize', 'appKeyId0', 'appKey0'],
628+
None,
629+
'',
630+
0,
631+
)
632+
633+
self._run_command(
634+
['account', 'authorize', 'appKeyId1', 'appKey1'],
635+
None,
636+
'',
637+
0,
638+
)
639+
584640
def test_update_bucket_without_lifecycle(self):
585641
# Start with authorizing with the master key
586642
self._authorize_account()
@@ -1028,6 +1084,109 @@ def test_keys(self):
10281084
1,
10291085
)
10301086

1087+
def test_multi_bucket_keys(self):
1088+
self._authorize_account()
1089+
1090+
self._run_command(['bucket', 'create', 'my-bucket-a', 'allPublic'], 'bucket_0\n', '', 0)
1091+
self._run_command(['bucket', 'create', 'my-bucket-b', 'allPublic'], 'bucket_1\n', '', 0)
1092+
self._run_command(['bucket', 'create', 'my-bucket-c', 'allPublic'], 'bucket_2\n', '', 0)
1093+
1094+
capabilities = ['readFiles', 'listBuckets']
1095+
capabilities_with_commas = ','.join(capabilities)
1096+
1097+
# Create a multi-bucket key with one of the buckets having invalid name
1098+
expected_stderr = 'Bucket not found: invalid. If you believe it exists, run `b2 bucket list` to reset cache, then try again.\n'
1099+
1100+
self._run_command(
1101+
[
1102+
'key',
1103+
'create',
1104+
'--bucket',
1105+
'my-bucket-a',
1106+
'--bucket',
1107+
'invalid',
1108+
'goodKeyName',
1109+
capabilities_with_commas,
1110+
],
1111+
'',
1112+
expected_stderr,
1113+
1,
1114+
)
1115+
1116+
# Create a multi-bucket key
1117+
self._run_command(
1118+
[
1119+
'key',
1120+
'create',
1121+
'--bucket',
1122+
'my-bucket-a',
1123+
'--bucket',
1124+
'my-bucket-b',
1125+
'goodKeyName',
1126+
capabilities_with_commas,
1127+
],
1128+
'appKeyId0 appKey0\n',
1129+
'',
1130+
0,
1131+
)
1132+
1133+
# List keys
1134+
expected_list_keys_out = 'appKeyId0 goodKeyName\n'
1135+
1136+
expected_list_keys_out_long = """
1137+
appKeyId0 goodKeyName my-bucket-a, my-bucket-b - - '' readFiles,listBuckets
1138+
"""
1139+
1140+
self._run_command(['key', 'list'], expected_list_keys_out, '', 0)
1141+
self._run_command(['key', 'list', '--long'], expected_list_keys_out_long, '', 0)
1142+
1143+
# authorize and make calls using an application key with bucket restrictions
1144+
self._run_command(['account', 'authorize', 'appKeyId0', 'appKey0'], None, '', 0)
1145+
1146+
self._run_command(
1147+
['bucket', 'list'],
1148+
'',
1149+
"ERROR: Application key is restricted to buckets: ['my-bucket-a', 'my-bucket-b']\n",
1150+
1,
1151+
)
1152+
self._run_command(
1153+
['bucket', 'get', 'my-bucket-c'],
1154+
'',
1155+
"ERROR: Application key is restricted to buckets: ['my-bucket-a', 'my-bucket-b']\n",
1156+
1,
1157+
)
1158+
1159+
def _get_expected_json(bucket_id: str, bucket_name: str):
1160+
return {
1161+
'accountId': self.account_id,
1162+
'bucketId': bucket_id,
1163+
'bucketInfo': {},
1164+
'bucketName': bucket_name,
1165+
'bucketType': 'allPublic',
1166+
'corsRules': [],
1167+
'defaultServerSideEncryption': {'mode': None},
1168+
'lifecycleRules': [],
1169+
'options': [],
1170+
'revision': 1,
1171+
}
1172+
1173+
self._run_command(
1174+
['bucket', 'get', 'my-bucket-a'],
1175+
expected_json_in_stdout=_get_expected_json('bucket_0', 'my-bucket-a'),
1176+
)
1177+
1178+
self._run_command(
1179+
['bucket', 'get', 'my-bucket-b'],
1180+
expected_json_in_stdout=_get_expected_json('bucket_1', 'my-bucket-b'),
1181+
)
1182+
1183+
self._run_command(
1184+
['ls', '--json', *self.b2_uri_args('my-bucket-c')],
1185+
'',
1186+
"ERROR: Application key is restricted to buckets: ['my-bucket-a', 'my-bucket-b']\n",
1187+
1,
1188+
)
1189+
10311190
def test_bucket_info_from_json(self):
10321191
self._authorize_account()
10331192
self._run_command(['bucket', 'create', 'my-bucket', 'allPublic'], 'bucket_0\n', '', 0)

0 commit comments

Comments
 (0)