Delegate access across AWS accounts using IAM roles

Keywords: AWS, IAM Role, Cross Account Access, Assume Role.

Summary

位于 Account A (acc_A) 上的用户想要访问位于 acc_B 上的资源,但就为了这个需求而在 acc_B 上创建一个 IAM 用户, 这显然不是一个 secure 也不 scale 的做法. 为了解决这个问题, AWS 官方推荐使用 IAM Roles 来实现跨账号访问. 本文将介绍如何使用 IAM Roles 来实现跨账号访问.

Reference:

How it Works

首先我们定义我们的目标, 我们要实现 acc_A 上的用户访问 acc_B. 我们定义被访问的账号叫做 owner account, 而需要访问权限的账号叫做 grantee account.

简单来说实现这个的原理是:

  • 在 owner account 上创建一个 IAM Role. trusted entity 里的要列出 grantee account 上需要访问权限的 principal. 这个 principal 可以是 Account Root, 也可以是特定的 IAM Group / User / Role, 也可以两种都用. 这两种方法各有优劣, 我们将在后面详细讨论.

  • 在 grantee account 上给需要访问权限的 principal 必要的 IAM policy permission, 里面要允许它 assume 在前一步在 owner account 上创建的 IAM Role.

  • 然后你在 grantee account 的 console 界面的右上角的 dropdown menu 里选择 Switch Role, 然后填写 owner account 的 account id 以及 role name 即可进入 owner account 的 console.

  • 如果你要用 CLI 访问, 那么你先创建一个 boto session, 然后调用 sts.assume_role API, 它会返回一些 token, 然后你再用这些 token 创建一个新的 boto session, 这个 session 就相当于是 owner account 上的 IAM Role 了.

下面是一些 IAM Policy 的例子:

Owner account 上的 IAM Role 的 trusted entity:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::${grantee_aws_account_id}:root",
                    "arn:aws:iam::${grantee_aws_account_id}:role/${iam_role_on_grantee_aws_account}",
                    "arn:aws:iam::${grantee_aws_account_id}:user/${iam_user_on_grantee_aws_account}",
                    "arn:aws:iam::${grantee_aws_account_id}:group/${iam_group_on_grantee_aws_account}"
                ]
            },
            "Action": "sts:AssumeRole",
            "Condition": {}
        }
    ]
}

Grantee account 上的 Principal 所需要的 IAM Policy:

{
    "Version": "2012-10-17",
    "Statement": {
        "Effect": "Allow",
        "Action": "sts:AssumeRole",
        "Resource": "arn:aws:iam::${owner_account_id}:role/${iam_role_on_owner_aws_account}"
    }
}

这里有个 best practice, 如果你的 Grantee account 上的 principal 配置了 sts:AssumeRole + *. 那么会导致你可以 assume owner account 上的 role, 这相当于默认运行了. 这种 cross account 的行文肯定应该是默认不允许的, 所以在最佳实践上你应该给除了 Admin 之外的任何人都配置 explicit deny. 这个最好是通过 User Group 来实现. 下面这个是 explicit deny 的 IAM Policy:

{
    "Version": "2012-10-17",
    "Statement": {
        "Effect": "Deny",
        "Action": "sts:AssumeRole",
        "Resource": "arn:aws:iam::${owner_account_id}:role/${iam_role_on_owner_aws_account}"
    }
}

Best Practice

我开发了一个自动化脚本, 能够自动的为 1 个 grantee account 和多个 owner accounts 配好 cross account access. 这个 grantee account 上的 identity 可以是整个 Account, 或是一个给人用的 IAM User, 也可以是一个给机器用的 IAM Role. 而 owner accounts 上的 Role 的权限可以是各不相同的.

  1# -*- coding: utf-8 -*-
  2
  3"""
  4This automation scripts can quickly setup a delegated cross AWS Account access
  5using IAM Role.
  6
  7Assuming you have a grantee AWS Account and multiple owner AWS Accounts.
  8The grantee AWS Account has an identity (IAM User or IAM Role) that needs to
  9assume IAM Role in the owner AWS Accounts to perform some tasks on owner
 10AWS Accounts.
 11
 12Please scroll to the bottom (below the ``if __name__ == "__main__":`` section)
 13to see the example usage.
 14
 15Pre-requisites:
 16
 17- Python3.8+
 18- boto3
 19- to verify the assume role works, you need ``boto_session_manager>=1.5.2``
 20
 21Reference:
 22
 23- attach_group_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/attach_group_policy.html
 24- attach_user_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/attach_user_policy.html
 25- attach_role_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/attach_role_policy.html
 26- detach_group_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/detach_group_policy.html
 27- detach_user_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/detach_user_policy.html
 28- detach_role_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/detach_role_policy.html
 29
 30- get_role: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/get_role.html
 31- get_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/get_policy.html
 32- create_role: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/create_role.html
 33- create_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/create_policy.html
 34- delete_role: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/delete_role.html
 35- delete_policy: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/iam/client/delete_policy.html
 36"""
 37
 38import typing as T
 39import json
 40import dataclasses
 41from datetime import datetime
 42from functools import cached_property
 43
 44import boto3
 45
 46
 47@dataclasses.dataclass
 48class Base:
 49    Tags: T.Optional[T.List[T.Dict[str, str]]] = dataclasses.field(default=None)
 50
 51    @property
 52    def tags_dict(self) -> T.Dict[str, str]:
 53        if self.Tags is None:
 54            return {}
 55        else:
 56            return {tag["Key"]: tag["Value"] for tag in self.Tags}
 57
 58
 59@dataclasses.dataclass
 60class IamManagedPolicy(Base):
 61    PolicyName: T.Optional[str] = dataclasses.field(default=None)
 62    PolicyId: T.Optional[str] = dataclasses.field(default=None)
 63    Arn: T.Optional[str] = dataclasses.field(default=None)
 64    Path: T.Optional[str] = dataclasses.field(default=None)
 65    DefaultVersionId: T.Optional[str] = dataclasses.field(default=None)
 66    AttachmentCount: T.Optional[int] = dataclasses.field(default=None)
 67    PermissionsBoundaryUsageCount: T.Optional[int] = dataclasses.field(default=None)
 68    IsAttachable: T.Optional[bool] = dataclasses.field(default=None)
 69    Description: T.Optional[str] = dataclasses.field(default=None)
 70    CreateDate: T.Optional[datetime] = dataclasses.field(default=None)
 71    UpdateDate: T.Optional[datetime] = dataclasses.field(default=None)
 72
 73    @classmethod
 74    def get(cls, iam_client, arn: str) -> T.Optional["IamManagedPolicy"]:
 75        try:
 76            return cls(**iam_client.get_policy(PolicyArn=arn)["Policy"])
 77        except Exception as e:
 78            if "not found" in str(e):
 79                return None
 80            else:  # pragma: no cover
 81                raise e
 82
 83
 84@dataclasses.dataclass
 85class IamRole(Base):
 86    Path: T.Optional[str] = dataclasses.field(default=None)
 87    RoleName: T.Optional[str] = dataclasses.field(default=None)
 88    RoleId: T.Optional[str] = dataclasses.field(default=None)
 89    Arn: T.Optional[str] = dataclasses.field(default=None)
 90    CreateDate: T.Optional[datetime] = dataclasses.field(default=None)
 91    AssumeRolePolicyDocument: T.Optional[str] = dataclasses.field(default=None)
 92    Description: T.Optional[str] = dataclasses.field(default=None)
 93    MaxSessionDuration: T.Optional[int] = dataclasses.field(default=None)
 94    PermissionsBoundary: T.Optional[dict] = dataclasses.field(default=None)
 95    RoleLastUsed: T.Optional[dict] = dataclasses.field(default=None)
 96
 97    @classmethod
 98    def get(cls, iam_client, name: str) -> T.Optional["IamRole"]:
 99        try:
100            return cls(**iam_client.get_role(RoleName=name)["Role"])
101        except Exception as e:
102            if "cannot be found" in str(e):
103                return None
104            else:  # pragma: no cover
105                raise e
106
107
108def get_aws_account_id(sts_client) -> str:
109    return sts_client.get_caller_identity()["Account"]
110
111
112def encode_tag(tags: T.Dict[str, str]) -> T.List[T.Dict[str, str]]:
113    return [{"Key": k, "Value": v} for k, v in tags.items()]
114
115
116@dataclasses.dataclass
117class Plan:
118    """
119    一个 Plan 对象代表了一个跨账号访问的计划, 包含了一个 grantee 账号上的一个 principal
120    和多个 owner 账号. 它会在 grantee 账号上创建 1 个 policy 并给这个 principal,
121    policy 的内容主要允许他 assume owner accounts 上面的 role. 它还会再每个 owner
122    account 上创建一个 role, 这个 role 里定义了它的权限, 并且允许 grantee 上的 principal
123    assume 这个 role.
124
125    :param grantee_boto_ses: the boto3 session of the grantee account
126    :param owner_boto_ses_list: list of boto3 sessions of the owner accounts
127    :param grantee_identity_arn: the arn of the identity on grantee account
128        you want to grant cross account access to
129    :param grantee_policy_name: the name of the policy to be created on grantee account
130    :param owner_role_name: the name of the role to be created on owner accounts
131    :param owner_policy_name: the name of the policy to be created on owner accounts
132    :param owner_policy_document_list: list of policy documents to be defined on
133        owner accounts' policies. The order of the list must match the order of
134        owner_boto_ses_list.
135    """
136
137    grantee_boto_ses: boto3.session.Session
138    owner_boto_ses_list: T.List[boto3.session.Session]
139    grantee_identity_arn: str
140    grantee_policy_name: str
141    owner_role_name: str
142    owner_policy_name: str
143    owner_policy_document_list: T.List[dict]
144    tags: T.Dict[str, str] = dataclasses.field(
145        default_factory=lambda: {
146            "meta:created_by": "cross-account-iam-role-access-manager"
147        }
148    )
149
150    def __post_init__(self):
151        if len(self.owner_boto_ses_list) != len(self.owner_policy_document_list):
152            raise ValueError(
153                "owner_boto_ses_list and owner_policy_document_list must have the same length"
154            )
155        if ":group/" in self.grantee_identity_arn:
156            raise ValueError("You cannot use IAM group as grantee identity")
157
158    @cached_property
159    def grantee_iam_client(self):
160        return self.grantee_boto_ses.client("iam")
161
162    @cached_property
163    def owner_iam_client_list(self) -> list:
164        return [boto_ses.client("iam") for boto_ses in self.owner_boto_ses_list]
165
166    @cached_property
167    def grantee_account_id(self) -> str:
168        return get_aws_account_id(self.grantee_boto_ses.client("sts"))
169
170    @cached_property
171    def owner_account_id_list(self) -> T.List[str]:
172        return [
173            get_aws_account_id(boto_ses.client("sts"))
174            for boto_ses in self.owner_boto_ses_list
175        ]
176
177    @cached_property
178    def grantee_policy_arn(self) -> str:
179        return (
180            f"arn:aws:iam::{self.grantee_account_id}:policy/{self.grantee_policy_name}"
181        )
182
183    @cached_property
184    def owner_policy_arn_list(self) -> T.List[str]:
185        return [
186            f"arn:aws:iam::{owner_account_id}:policy/{self.owner_policy_name}"
187            for owner_account_id in self.owner_account_id_list
188        ]
189
190    @cached_property
191    def owner_role_arn_list(self) -> T.List[str]:
192        return [
193            f"arn:aws:iam::{owner_account_id}:role/{self.owner_role_name}"
194            for owner_account_id in self.owner_account_id_list
195        ]
196
197    def setup_grantee_account(self):
198        print(f"Setup grantee account {self.grantee_account_id}")
199        print(
200            f"  Create policy {self.grantee_policy_name!r} on "
201            f"grantee account {self.grantee_account_id}"
202        )
203        policy = IamManagedPolicy.get(
204            self.grantee_iam_client,
205            arn=self.grantee_policy_arn,
206        )
207        if policy is None:
208            policy_document = {
209                "Version": "2012-10-17",
210                "Statement": [
211                    {
212                        "Effect": "Allow",
213                        "Action": "sts:AssumeRole",
214                        "Resource": [
215                            f"arn:aws:iam::{owner_account_id}:role/{self.owner_role_name}"
216                            for owner_account_id in self.owner_account_id_list
217                        ],
218                    },
219                ],
220            }
221            self.grantee_iam_client.create_policy(
222                PolicyName=self.grantee_policy_name,
223                PolicyDocument=json.dumps(policy_document),
224                Tags=encode_tag(self.tags),
225            )
226            print("    Succeed!")
227        else:
228            print(
229                f"    Grantee account already has the policy {self.grantee_policy_name!r}!"
230            )
231
232        print(
233            f"  Attach Policy {self.grantee_policy_arn} to {self.grantee_identity_arn}"
234        )
235        if ":group/" in self.grantee_identity_arn:
236            raise ValueError("You cannot use IAM group as grantee identity")
237        elif ":user/" in self.grantee_identity_arn:
238            self.grantee_iam_client.attach_user_policy(
239                UserName=self.grantee_identity_arn.split("/")[-1],
240                PolicyArn=self.grantee_policy_arn,
241            )
242        elif ":role/" in self.grantee_identity_arn:
243            self.grantee_iam_client.attach_role_policy(
244                RoleName=self.grantee_identity_arn.split("/")[-1],
245                PolicyArn=self.grantee_policy_arn,
246            )
247        elif self.grantee_identity_arn.endswith(":root"):
248            # grantee is root aws account, no need to attach anything
249            pass
250        else:
251            raise NotImplementedError
252        print("    Done!")
253
254    def setup_one_owner_account(
255        self,
256        account_id: str,
257        iam_client,
258        policy_arn: str,
259        policy_document: str,
260        role_arn: str,
261    ):
262        print(f"  Setup owner account {account_id}")
263        # Create Role
264        print(f"    Create role {self.owner_role_name!r} on owner account {account_id}")
265        role = IamRole.get(
266            iam_client,
267            name=self.owner_role_name,
268        )
269        if role is None:
270            trusted_relationship_policy_document = {
271                "Version": "2012-10-17",
272                "Statement": [
273                    {
274                        "Effect": "Allow",
275                        "Principal": {
276                            "AWS": [
277                                self.grantee_identity_arn,
278                            ]
279                        },
280                        "Action": "sts:AssumeRole",
281                    },
282                ],
283            }
284            iam_client.create_role(
285                RoleName=self.owner_role_name,
286                AssumeRolePolicyDocument=json.dumps(
287                    trusted_relationship_policy_document
288                ),
289                Tags=encode_tag(self.tags),
290            )
291            print("    Succeed!")
292        else:
293            print(f"      Owner account already has the role {self.owner_role_name!r}!")
294
295        # Create Policy
296        print(
297            f"    Create policy {self.owner_policy_name!r} on owner account {account_id}"
298        )
299        policy = IamManagedPolicy.get(
300            iam_client,
301            arn=policy_arn,
302        )
303        if policy is None:
304            iam_client.create_policy(
305                PolicyName=self.owner_policy_name,
306                PolicyDocument=json.dumps(policy_document),
307                Tags=encode_tag(self.tags),
308            )
309            print("      Succeed!")
310        else:
311            print(
312                f"      Owner account already has the policy {self.owner_policy_name!r}!"
313            )
314
315        # Attach Policy
316        print(f"    Attach Policy {policy_arn} to {role_arn}")
317        iam_client.attach_role_policy(
318            RoleName=self.owner_role_name,
319            PolicyArn=policy_arn,
320        )
321        print("      Done!")
322
323    def setup_owner_account(self):
324        print("Setup owner accounts ...")
325        for tp in zip(
326            self.owner_account_id_list,
327            self.owner_iam_client_list,
328            self.owner_policy_arn_list,
329            self.owner_policy_document_list,
330            self.owner_role_arn_list,
331        ):
332            self.setup_one_owner_account(
333                account_id=tp[0],
334                iam_client=tp[1],
335                policy_arn=tp[2],
336                policy_document=tp[3],
337                role_arn=tp[4],
338            )
339
340    def deploy(self):
341        self.setup_grantee_account()
342        self.setup_owner_account()
343
344    def detach_policies_in_owner_account(self):
345        print("Detach policies in owner accounts ...")
346        for (
347            owner_account_id,
348            owner_iam_client,
349            owner_policy_arn,
350            owner_role_arn,
351        ) in zip(
352            self.owner_account_id_list,
353            self.owner_iam_client_list,
354            self.owner_policy_arn_list,
355            self.owner_role_arn_list,
356        ):
357            print(f"  Detach policy in owner account {owner_account_id}")
358            role = IamRole.get(owner_iam_client, self.owner_role_name)
359            try:
360                if role is not None:
361                    print(
362                        f"      Detach policy {owner_policy_arn} from role {role.Arn}"
363                    )
364                    owner_iam_client.detach_role_policy(
365                        RoleName=self.owner_role_name,
366                        PolicyArn=owner_policy_arn,
367                    )
368            except Exception as e:
369                if "was not found" in str(e):
370                    pass
371                else:
372                    raise e
373
374    def detach_policies_in_grantee_account(self):
375        print(f"Detach policies in grantee account {self.grantee_account_id} ...")
376        print("  Detach policy from grantee identity")
377        if ":group/" in self.grantee_identity_arn:
378            raise ValueError("You cannot use IAM group as grantee identity")
379        elif ":user/" in self.grantee_identity_arn:
380            try:
381                self.grantee_iam_client.detach_user_policy(
382                    UserName=self.grantee_identity_arn.split("/")[-1],
383                    PolicyArn=self.grantee_policy_arn,
384                )
385            except Exception as e:
386                if "was not found" in str(e):
387                    pass
388                else:
389                    raise e
390        elif ":role/" in self.grantee_identity_arn:
391            try:
392                self.grantee_iam_client.detach_role_policy(
393                    RoleName=self.grantee_identity_arn.split("/")[-1],
394                    PolicyArn=self.grantee_policy_arn,
395                )
396            except Exception as e:
397                if "was not found" in str(e):
398                    pass
399                else:
400                    raise e
401        elif self.grantee_identity_arn.endswith(":root"):
402            # grantee is root aws account, no need to detach anything
403            pass
404        else:
405            raise NotImplementedError
406
407    def detach(self):
408        self.detach_policies_in_owner_account()
409        self.detach_policies_in_grantee_account()
410
411    def delete_iam_resources_in_owner_account(self):
412        print("Delete IAM resources in owner accounts ...")
413        for (
414            owner_account_id,
415            owner_iam_client,
416            owner_policy_arn,
417            owner_role_arn,
418        ) in zip(
419            self.owner_account_id_list,
420            self.owner_iam_client_list,
421            self.owner_policy_arn_list,
422            self.owner_role_arn_list,
423        ):
424            print(f"  Clean up owner account {owner_account_id}")
425            print(f"    Delete policy {owner_policy_arn}")
426            if IamManagedPolicy.get(owner_iam_client, owner_policy_arn) is not None:
427                owner_iam_client.delete_policy(PolicyArn=owner_policy_arn)
428
429            print(f"    Delete role {owner_role_arn}")
430            role = IamRole.get(owner_iam_client, self.owner_role_name)
431            if role is not None:
432                owner_iam_client.delete_role(RoleName=self.owner_role_name)
433
434    def delete_iam_resources_in_grantee_account(self):
435        print(f"Delete IAM resources in grantee account {self.grantee_account_id} ...")
436        print(f"  Delete policy {self.grantee_policy_arn}")
437        if (
438            IamManagedPolicy.get(self.grantee_iam_client, self.grantee_policy_arn)
439            is not None
440        ):
441            self.grantee_iam_client.delete_policy(PolicyArn=self.grantee_policy_arn)
442
443    def delete(self):
444        self.delete_iam_resources_in_owner_account()
445        self.delete_iam_resources_in_grantee_account()
446
447
448def verify(
449    plan: Plan,
450    grantee_boto_ses: boto3.session.Session,
451):
452    from boto_session_manager import BotoSesManager
453
454    print("Verify cross account assume role ...")
455    bsm = BotoSesManager(botocore_session=grantee_boto_ses._session)
456    res = bsm.sts_client.get_caller_identity()
457    grantee_principal_arn = res["Arn"]
458    print(
459        f"We are on grantee account {bsm.aws_account_id}, "
460        f"using principal {grantee_principal_arn}"
461    )
462    for owner_role_arn in plan.owner_role_arn_list:
463        print(f"Try to assume role {owner_role_arn} on owner account")
464        bsm_new = bsm.assume_role(role_arn=owner_role_arn)
465        res = bsm_new.sts_client.get_caller_identity()
466        account_id = res["Account"]
467        arn = res["Arn"]
468        print(f"  now we are on account {account_id}, using principal {arn}")
469
470
471if __name__ == "__main__":
472    # --------------------------------------------------------------------------
473    # update the following variables to your own values
474    #  --------------------------------------------------------------------------
475    # the boto3 session of the grantee account
476    grantee_boto_ses = boto3.session.Session(profile_name="bmt_app_dev_us_east_1")
477
478    # list of boto3 sessions of the owner accounts
479    owner_boto_ses_list = [
480        boto3.session.Session(profile_name="bmt_app_dev_us_east_1"),
481        boto3.session.Session(profile_name="bmt_app_prod_us_east_1"),
482    ]
483
484    # the arn of the identity on grantee account you want to grant cross account access to
485    grantee_account_id = get_aws_account_id(grantee_boto_ses.client("sts"))
486
487    # the name of the policy to be created on grantee account
488    grantee_policy_name = "cross-account-deployer-policy"
489
490    # the name of the role to be created on owner accounts
491    owner_role_name = "cross-account-deployer-role"
492
493    # the name of the policy to be created on owner accounts
494    owner_policy_name = "cross-account-deployer-policy"
495
496    # list of policy documents to be defined on owner accounts' policies.
497    # The order of the list must match the order of owner_boto_ses_list.
498    # this example policy allow grantee account to get caller identity
499    # so you can verify the assume role works
500    owner_policy_document = {
501        "Version": "2012-10-17",
502        "Statement": [
503            {
504                "Effect": "Allow",
505                "Action": "*",
506                "Resource": "*",
507            },
508        ],
509    }
510    owner_policy_document_list = [
511        owner_policy_document,
512        owner_policy_document,
513    ]
514    # --------------------------------------------------------------------------
515    # end of configuration
516    # --------------------------------------------------------------------------
517    grantee_identity_arn_list = [
518        f"arn:aws:iam::{grantee_account_id}:user/sanhe",
519    ]
520    plan_list = []
521    for grantee_identity_arn in grantee_identity_arn_list:
522        plan = Plan(
523            grantee_boto_ses=grantee_boto_ses,
524            owner_boto_ses_list=owner_boto_ses_list,
525            grantee_identity_arn=grantee_identity_arn,
526            grantee_policy_name=grantee_policy_name,
527            owner_role_name=owner_role_name,
528            owner_policy_name=owner_policy_name,
529            owner_policy_document_list=owner_policy_document_list,
530        )
531        plan_list.append(plan)
532
533    # --- deploy
534    for plan in plan_list:
535        plan.deploy()  # deploy it
536        verify(plan, grantee_boto_ses)  # verify the cross account assume role works
537
538    # --- clean up
539    # for plan in plan_list:
540    #     plan.detach()
541    # for plan in plan_list:
542    #     plan.delete()