Aws-cdk: A Principal that represents an MFA-authenticated user

Created on 14 Jan 2020  路  9Comments  路  Source: aws/aws-cdk

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.

Reproduction Steps

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]))

Resulting permission policy

{
  "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"
        }
      }
    }
  ]
}

Expected permission policy

{
  "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"
        }
      }
    }
  ]
}

Technical Details

CDK version: 1.20.0

@aws-cdaws-iam efforsmall feature-request p2

Most helpful comment

You can solve this yourself by writing an class that implements IPrincipal which returns the policy fragment that you need.

All 9 comments

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

Was this page helpful?
0 / 5 - 0 ratings

Related issues

pepastach picture pepastach  路  3Comments

kawamoto picture kawamoto  路  3Comments

abelmokadem picture abelmokadem  路  3Comments

Kent1 picture Kent1  路  3Comments

v-do picture v-do  路  3Comments