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:
IAM tutorial: Delegate access across AWS accounts using IAM roles: https://docs.aws.amazon.com/IAM/latest/UserGuide/tutorial_cross-account-with-roles.html
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()