When trying to define a role that is only assumable when MFA is set, the permission policy of the role can't be modified but only complemented. This is due to the fact, that iam.Role has its argument assumed_by set as required, which automatically sets a policy statement in the permission policy. Later, when setting the MFA requirement via role.assume_role_policy.add_statements(iam.PolicyStatement(...)) it is required to set at least the arguments principals, actions, and conditions. The problem is that this adds another policy statement to the permission policy, which renders the MFA condition useless and allows bypassing it.
from aws_cdk import (
aws_iam as iam,
core,
)
class AssumeRoleStack(core.Stack):
def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
user = iam.User(self, 'myuser')
role = iam.Role(self, 'myrole',
assumed_by=iam.ArnPrincipal(user.user_arn))
role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name('AdministratorAccess'))
role.assume_role_policy.add_statements(
iam.PolicyStatement(principals=[user],
actions=['sts:AssumeRole'],
conditions={'Bool': {'aws:MultiFactorAuthPresent': True}})
)
user.add_to_policy(iam.PolicyStatement(actions=['sts:AssumeRole'], resources=[role.role_arn]))
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::012345678910:user/assume-role-myuserZ09A543B-1ULCILBM447SF"
},
"Action": "sts:AssumeRole"
},
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::012345678910:user/assume-role-myuserZ09A543B-1ULCILBM447SF"
},
"Action": "sts:AssumeRole",
"Condition": {
"Bool": {
"aws:MultiFactorAuthPresent": "true"
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::012345678910:user/assume-role-myuserZ09A543B-1ULCILBM447SF"
},
"Action": "sts:AssumeRole",
"Condition": {
"Bool": {
"aws:MultiFactorAuthPresent": "true"
}
}
}
]
}
CDK version: 1.20.0
You can solve this yourself by writing an class that implements IPrincipal which returns the policy fragment that you need.
Here's an example in js proving out @rix0rrr's suggestion:
[REDACTED] This example did not work in other languages. I have provided an updated version of the example below.
In Python it doesn't work:
class MultiFactorAuthPrincipal(iam.IPrincipal):
def __init__(self):
self.root_account_number = core.Aws.ACCOUNT_ID
def assume_role_action(self) -> str:
return 'sts:AssumeRole'
def policy_fragment(self) -> iam.PrincipalPolicyFragment:
return iam.PrincipalPolicyFragment(
principal_json={'AWS': [f'arn:aws:iam::{self.root_account_number}:root']},
conditions={'Bool': {'aws:MultiFactorAuthPresent': True}}
)
def add_to_policy(self, statement: iam.PolicyStatement) -> bool:
return False
class AssumeRoleStack(core.Stack):
def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
user = MultiFactorAuthPrincipal()
role = iam.Role(self, 'myrole',
assumed_by=user,
max_session_duration=core.Duration.hours(8))
# ...
CDK complains about not being able to convert to JSON:
Traceback (most recent call last): File "app.py", line 10, in <module>
AssumeRoleStack(app, 'assume-role', env=env_DE)
File "/path/to/app/.venv/lib/python3.7/site-packages/jsii/_runtime.py", line 66, in __call__
inst = super().__call__(*args, **kwargs)
File "/path/to/app/assume_role/assume_role_stack.py", line 41, in __init__
max_session_duration=core.Duration.hours(8))
File "/path/to/app/.venv/lib/python3.7/site-packages/jsii/_runtime.py", line 66, in __call__
inst = super().__call__(*args, **kwargs)
File "/path/to/app/.venv/lib/python3.7/site-packages/aws_cdk/aws_iam/__init__.py", line 4537, in __init__
jsii.create(Role, self, [scope, id, props])
File "/path/to/app/.venv/lib/python3.7/site-packages/jsii/_kernel/__init__.py", line 229, in create
interfaces=[iface.__jsii_type__ for iface in getattr(klass, "__jsii_ifaces__", [])],
File "/path/to/app/.venv/lib/python3.7/site-packages/jsii/_kernel/providers/process.py", line 333, in create
return self._process.send(request, CreateResponse)
File "/path/to/app/.venv/lib/python3.7/site-packages/jsii/_kernel/providers/process.py", line 303, in send
data = json.dumps(req_dict, default=jdefault).encode("utf8")
File "/Users/my_user/.pyenv/versions/3.7.4/lib/python3.7/json/__init__.py", line 238, in dumps
**kw).encode(obj)
File "/Users/my_user/.pyenv/versions/3.7.4/lib/python3.7/json/encoder.py", line 199, in encode
chunks = self.iterencode(o, _one_shot=True)
File "/Users/my_user/.pyenv/versions/3.7.4/lib/python3.7/json/encoder.py", line 257, in iterencode
return _iterencode(o, 0)
File "/path/to/app/.venv/lib/python3.7/site-packages/jsii/_kernel/providers/process.py", line 146, in jdefault
raise TypeError("Don't know how to convert object to JSON: %r" % obj)
TypeError: Don't know how to convert object to JSON: <assume_role.assume_role_stack.MultiFactorAuthPrincipal object at 0x1041217d0>
Subprocess exited with error 1
Time: 0h:00m:03s
What am I missing?
The missing piece is provided by PrincipalBase.
Turns out this can be simplified, but you do need to extend PrincipalBase
I'm going to remove the code example above and point here to remove potential confusion.
const { PrincipalBase, PrincipalPolicyFragment } = require('@aws-cdk/aws-iam');
module.exports = {
MultiFactorAuthPrincipal: class MultiFactorAuthPrincipal extends PrincipalBase {
constructor(rootAccountNumber) {
super();
this.rootAccountNumber = rootAccountNumber;
}
get policyFragment() {
return new PrincipalPolicyFragment({
AWS: [`arn:aws:iam::${this.rootAccountNumber}:root`]
}, {
Bool: {
'aws:MultiFactorAuthPresent': 'true'
}
});
}
}
};
It now fails with another error:
jsii.errors.JavaScriptError:
TypeError: Cannot read property 'principalJson' of undefined
I'm not familiar enough with python to help troubleshoot with you @milo0. I have a functional js stack using the class I showed above, but there may be an additional trick in python.
Definitely post back if you figure it out!
I figured it out now. First of all, policy_fragment needs to be defined as a property, not as a method. You have to create a class that inherits from iam.AccountRootPrincipal and override policy_fragment so that the returned trust policy contains the Conditions block. You can then pass an instance of this new class to the assumed_by argument of your role:
from aws_cdk import (
aws_iam as iam,
core,
)
class MFAAccountRootPrincipal(iam.AccountRootPrincipal):
def __init__(self):
super().__init__()
@property
def policy_fragment(self) -> iam.PrincipalPolicyFragment:
return iam.PrincipalPolicyFragment(
principal_json={'AWS': [self.arn]},
conditions={'Bool': {'aws:MultiFactorAuthPresent': True}}
)
class AssumeRoleStack(core.Stack):
def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
user = iam.User(self, 'myuser')
role = iam.Role(self, 'MFA_Admin_Role',
assumed_by=MFAAccountRootPrincipal(),
managed_policies=[iam.ManagedPolicy.from_aws_managed_policy_name('AdministratorAccess')],
max_session_duration=core.Duration.hours(8))
user.add_to_policy(iam.PolicyStatement(actions=['sts:AssumeRole'], resources=[role.role_arn]))
Note that this doesn't allow for trusting accounts other than the one where the user & role are defined. However, this can be solved easily by passing an account number or instance of core.Environment as an argument to the AssumeRoleStack initializer. Passing this down to the initializer of MFAAccountRootPrincipal you can then manually construct the ARN of the trusted account's root user.
Thanks to rix0rrr and terodox for your help!
Ahhh, I see the confusion - in javascript the get policyFragment() syntax makes a policyFragment property with only a getter on it.
Not sure if it is better, but I found a reasonably simple way to add the missing condition:
```python script
from aws_cdk import (
aws_iam as iam,
core
)
class AdminRole(core.Construct):
def __init__(self, scope: core.Construct, id: str, *kwargs) -> None:
super().__init__(scope, id, *kwargs)
role = iam.Role(
scope=self,
id='Role',
assumed_by=iam.CompositePrincipal(
iam.AccountPrincipal("111111111111"),
iam.AccountPrincipal("222222222222"),
),
description='my Administrator role',
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name('AdministratorAccess'),
],
max_session_duration=core.Duration.hours(8),
path='/',
role_name='MyAdmin'
)
# add (not yet supported) conditional statement
cfn_resource = role.node.find_child('Resource')
cfn_resource.add_override('Properties.AssumeRolePolicyDocument.Statement.0.Condition.Bool',
{'aws:MultiFactorAuthPresent': True})
```
This is a generic way of adding any missing Properties from the intent-based constructor, down to the low level Cfn object
Most helpful comment
You can solve this yourself by writing an class that implements
IPrincipalwhich returns the policy fragment that you need.