Skip to content

Commit 89bf0b5

Browse files
xnoxtoabctl
authored andcommitted
Add support for sharing with Organizations and Organizational Units
This PR extends "share:" schema to support sharing images with organizations & organization units by ARN. Fixes: #105
1 parent ae17f0e commit 89bf0b5

File tree

8 files changed

+111
-27
lines changed

8 files changed

+111
-27
lines changed

awspub/common.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,20 @@ def _split_partition(val: str) -> Tuple[str, str]:
1515
:return: the partition and the resource
1616
:rtype: Tuple[str, str]
1717
"""
18-
if ":" in val:
19-
partition, resource = val.split(":")
20-
else:
21-
# if no partition is given, assume default commercial partition "aws"
22-
partition = "aws"
23-
resource = val
24-
return partition, resource
18+
19+
# ARNs encode partition https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html
20+
if val.startswith("arn:"):
21+
arn, partition, resource = val.split(":", maxsplit=2)
22+
# Return extracted partition, but keep full ARN intact
23+
return partition, val
24+
25+
# Partition prefix
26+
if ":" in val and val.startswith("aws"):
27+
partition, resource = val.split(":", maxsplit=1)
28+
return partition, resource
29+
30+
# if no partition is given, assume default commercial partition "aws"
31+
return "aws", val
2532

2633

2734
def _get_regions(region_to_query: str, regions_allowlist: List[str]) -> List[str]:

awspub/configmodels.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pathlib
2+
import re
23
from enum import Enum
34
from typing import Dict, List, Literal, Optional
45

@@ -161,8 +162,8 @@ class ConfigImageModel(BaseModel):
161162
)
162163
imds_support: Optional[Literal["v2.0"]] = Field(description="Optional IMDS support", default=None)
163164
share: Optional[List[str]] = Field(
164-
description="Optional list of account IDs the image and snapshot will be shared with. The account"
165-
"ID can be prefixed with the partition and separated by ':'. Eg 'aws-cn:123456789123'",
165+
description="Optional list of account IDs, organization ARN, OU ARN the image and snapshot will be shared with."
166+
" The account ID can be prefixed with the partition and separated by ':'. Eg 'aws-cn:123456789123'",
166167
default=None,
167168
)
168169
temporary: Optional[bool] = Field(
@@ -193,11 +194,25 @@ def check_share(cls, v: Optional[List[str]]) -> Optional[List[str]]:
193194
"""
194195
Make sure the account IDs are valid and if given the partition is correct
195196
"""
197+
patterns = [
198+
# https://docs.aws.amazon.com/organizations/latest/APIReference/API_Account.html
199+
r"\d{12}",
200+
# Adjusted for partitions
201+
# https://docs.aws.amazon.com/organizations/latest/APIReference/API_Organization.html
202+
r"arn:aws(?:-cn)?(?:-us-gov)?:organizations::\d{12}:organization\/o-[a-z0-9]{10,32}",
203+
# https://docs.aws.amazon.com/organizations/latest/APIReference/API_OrganizationalUnit.html
204+
r"arn:aws(?:-cn)?(?:-us-gov)?:organizations::\d{12}:ou\/o-[a-z0-9]{10,32}\/ou-[0-9a-z]{4,32}-[0-9a-z]{8,32}", # noqa:E501
205+
]
196206
if v is not None:
197207
for val in v:
198-
partition, account_id = _split_partition(val)
199-
if len(account_id) != 12:
200-
raise ValueError("Account ID must be 12 characters long")
208+
partition, account_id_or_arn = _split_partition(val)
209+
valid = False
210+
for pattern in patterns:
211+
if re.fullmatch(pattern, account_id_or_arn):
212+
valid = True
213+
break
214+
if not valid:
215+
raise ValueError("Account ID must be 12 digits long or an ARN for Organization or OU")
201216
if partition not in ["aws", "aws-cn", "aws-us-gov"]:
202217
raise ValueError("Partition must be one of 'aws', 'aws-cn', 'aws-us-gov'")
203218
return v

awspub/image.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
from dataclasses import dataclass
44
from enum import Enum
5-
from typing import Any, Dict, List, Optional
5+
from typing import Any, Dict, List, Optional, Tuple
66

77
import boto3
88
import botocore.exceptions
@@ -145,23 +145,30 @@ def _tags(self):
145145
tags.append({"Key": name, "Value": value})
146146
return tags
147147

148-
def _share_list_filtered(self, share_conf: List[str]) -> List[Dict[str, str]]:
148+
def _share_list_filtered(self, share_conf: List[str]) -> Tuple[List[Dict[str, str]], List[Dict[str, str]]]:
149149
"""
150150
Get a filtered list of share configurations based on the current partition
151151
:param share_conf: the share configuration
152152
:type share_conf: List[str]
153153
:return: a List of share configurations that is usable by modify_image_attribute()
154-
:rtype: List[Dict[str, str]]
154+
:rtype: Tuple[List[Dict[str, str]], List[Dict[str, str]]]
155155
"""
156156
# the current partition
157157
partition_current = boto3.client("ec2").meta.partition
158158

159159
share_list: List[Dict[str, str]] = []
160+
volume_list: List[Dict[str, str]] = []
160161
for share in share_conf:
161-
partition, account_id = _split_partition(share)
162+
partition, account_id_or_arn = _split_partition(share)
162163
if partition == partition_current:
163-
share_list.append({"UserId": account_id})
164-
return share_list
164+
if ":organization/o-" in account_id_or_arn:
165+
share_list.append({"OrganizationArn": account_id_or_arn})
166+
elif ":ou/o-" in account_id_or_arn:
167+
share_list.append({"OrganizationalUnitArn": account_id_or_arn})
168+
else:
169+
share_list.append({"UserId": account_id_or_arn})
170+
volume_list.append({"UserId": account_id_or_arn})
171+
return share_list, volume_list
165172

166173
def _share(self, share_conf: List[str], images: Dict[str, _ImageInfo]):
167174
"""
@@ -172,7 +179,7 @@ def _share(self, share_conf: List[str], images: Dict[str, _ImageInfo]):
172179
:param images: a Dict with region names as keys and _ImageInfo objects as values
173180
:type images: Dict[str, _ImageInfo]
174181
"""
175-
share_list = self._share_list_filtered(share_conf)
182+
share_list, volume_list = self._share_list_filtered(share_conf)
176183

177184
if not share_list:
178185
logger.info("no valid accounts found for sharing in this partition, skipping")
@@ -188,11 +195,11 @@ def _share(self, share_conf: List[str], images: Dict[str, _ImageInfo]):
188195
)
189196

190197
# modify snapshot permissions
191-
if image_info.snapshot_id:
198+
if image_info.snapshot_id and volume_list:
192199
ec2client.modify_snapshot_attribute(
193200
Attribute="createVolumePermission",
194201
SnapshotId=image_info.snapshot_id,
195-
CreateVolumePermission={"Add": share_list}, # type: ignore
202+
CreateVolumePermission={"Add": volume_list}, # type: ignore
196203
)
197204

198205
logger.info(f"shared images & snapshots with '{share_conf}'")

awspub/tests/fixtures/config1.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ awspub:
8383
- "221020170000"
8484
- "aws:290620200000"
8585
- "aws-cn:334455667788"
86+
- "arn:aws:organizations::123456789012:organization/o-123example"
87+
- "arn:aws-cn:organizations::334455667788:ou/o-123example/ou-1234-5example"
8688
marketplace:
8789
entity_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
8890
access_role_arn: "arn:aws:iam::xxxxxxxxxxxx:role/AWSMarketplaceAccess"

awspub/tests/test_common.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,30 @@
1212
("aws:123456789123", ("aws", "123456789123")),
1313
("aws-cn:123456789123", ("aws-cn", "123456789123")),
1414
("aws-us-gov:123456789123", ("aws-us-gov", "123456789123")),
15+
(
16+
"arn:aws:organizations::123456789012:organization/o-123example",
17+
("aws", "arn:aws:organizations::123456789012:organization/o-123example"),
18+
),
19+
(
20+
"arn:aws:organizations::123456789012:ou/o-123example/ou-1234-5example",
21+
("aws", "arn:aws:organizations::123456789012:ou/o-123example/ou-1234-5example"),
22+
),
23+
(
24+
"arn:aws-cn:organizations::123456789012:organization/o-123example",
25+
("aws-cn", "arn:aws-cn:organizations::123456789012:organization/o-123example"),
26+
),
27+
(
28+
"arn:aws-cn:organizations::123456789012:ou/o-123example/ou-1234-5example",
29+
("aws-cn", "arn:aws-cn:organizations::123456789012:ou/o-123example/ou-1234-5example"),
30+
),
31+
(
32+
"arn:aws-us-gov:organizations::123456789012:organization/o-123example",
33+
("aws-us-gov", "arn:aws-us-gov:organizations::123456789012:organization/o-123example"),
34+
),
35+
(
36+
"arn:aws-us-gov:organizations::123456789012:ou/o-123example/ou-1234-5example",
37+
("aws-us-gov", "arn:aws-us-gov:organizations::123456789012:ou/o-123example/ou-1234-5example"),
38+
),
1539
],
1640
)
1741
def test_common__split_partition(input, expected_output):

awspub/tests/test_image.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -478,14 +478,38 @@ def test_image__verify(image_found, config, config_image_name, expected_problems
478478

479479

480480
@pytest.mark.parametrize(
481-
"partition,imagename,share_list_expected",
481+
"partition,imagename,share_list_expected,volume_list_expected",
482482
[
483-
("aws", "test-image-8", [{"UserId": "123456789123"}, {"UserId": "221020170000"}, {"UserId": "290620200000"}]),
484-
("aws-cn", "test-image-8", [{"UserId": "334455667788"}]),
485-
("aws-us-gov", "test-image-8", []),
483+
(
484+
"aws",
485+
"test-image-8",
486+
[
487+
{"UserId": "123456789123"},
488+
{"UserId": "221020170000"},
489+
{"UserId": "290620200000"},
490+
{"OrganizationArn": "arn:aws:organizations::123456789012:organization/o-123example"},
491+
],
492+
[
493+
{"UserId": "123456789123"},
494+
{"UserId": "221020170000"},
495+
{"UserId": "290620200000"},
496+
],
497+
),
498+
(
499+
"aws-cn",
500+
"test-image-8",
501+
[
502+
{"UserId": "334455667788"},
503+
{"OrganizationalUnitArn": "arn:aws-cn:organizations::334455667788:ou/o-123example/ou-1234-5example"},
504+
],
505+
[
506+
{"UserId": "334455667788"},
507+
],
508+
),
509+
("aws-us-gov", "test-image-8", [], []),
486510
],
487511
)
488-
def test_image__share_list_filtered(partition, imagename, share_list_expected):
512+
def test_image__share_list_filtered(partition, imagename, share_list_expected, volume_list_expected):
489513
"""
490514
Test _share_list_filtered() for a given image
491515
"""
@@ -494,7 +518,7 @@ def test_image__share_list_filtered(partition, imagename, share_list_expected):
494518
instance.meta.partition = partition
495519
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
496520
img = image.Image(ctx, imagename)
497-
assert img._share_list_filtered(img.conf["share"]) == share_list_expected
521+
assert img._share_list_filtered(img.conf["share"]) == (share_list_expected, volume_list_expected)
498522

499523

500524
@patch("awspub.s3.S3.bucket_region", return_value="region1")

docs/config-samples/config-minimal-share.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ awspub:
1010
share:
1111
- "123456789123"
1212
- "aws-cn:456789012345"
13+
- "arn:aws:organizations::123456789012:organization/o-123example"
14+
- "arn:aws-cn:organizations::334455667788:ou/o-123example/ou-1234-5example"

docs/how_to/publish.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ In the above example, the image `my-custom-image` will be shared with the accoun
199199
when `awspub` runs in the commercial partition (``aws``, the default). It'll be shared
200200
with the account `456789012345` when `awspub` runs in the the china partition (``aws-cn``).
201201

202+
Also sharing with an organization and organisation units is available by organization or organisational
203+
unit ARN (which already encodes partition).
204+
202205
AWS Marketplace
203206
~~~~~~~~~~~~~~~
204207

0 commit comments

Comments
 (0)