Skip to content

Commit

Permalink
feat(core): CfnJson enables intrinsics in hash keys (#8099)
Browse files Browse the repository at this point in the history
Rarely there is a need to use a CloudFormation intrinsic in a JSON hash key. This is impossible since intrinsics are not strings. To circumvent that, CfnJson can be used to "delay" the rendition of the object using a simple custom resource
which simply parses and reflects the input value as an attribute.

Required in order to implement EKS IRSA (#8084) and other federated identity use cases.


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
Elad Ben-Israel authored May 21, 2020
1 parent 3fed84b commit 195cd40
Show file tree
Hide file tree
Showing 14 changed files with 509 additions and 5 deletions.
5 changes: 5 additions & 0 deletions packages/@aws-cdk/aws-iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ const principal = new iam.AccountPrincipal('123456789000')
.withConditions({ StringEquals: { foo: "baz" } });
```
> NOTE: If you need to define an IAM condition that uses a token (such as a
> deploy-time attribute of another resource) in a JSON map key, use `CfnJson` to
> render this condition. See [this test](./test/integ-condition-with-ref.ts) for
> an example.
The `WebIdentityPrincipal` class can be used as a principal for web identities like
Cognito, Amazon, Google or Facebook, for example:
Expand Down
18 changes: 17 additions & 1 deletion packages/@aws-cdk/aws-iam/lib/principals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,24 @@ export class PrincipalWithConditions implements IPrincipal {
Object.entries(principalConditions).forEach(([operator, condition]) => {
mergedConditions[operator] = condition;
});

Object.entries(additionalConditions).forEach(([operator, condition]) => {
mergedConditions[operator] = { ...mergedConditions[operator], ...condition };
// merge the conditions if one of the additional conditions uses an
// operator that's already used by the principal's conditions merge the
// inner structure.
const existing = mergedConditions[operator];
if (!existing) {
mergedConditions[operator] = condition;
return; // continue
}

// if either the existing condition or the new one contain unresolved
// tokens, fail the merge. this is as far as we go at this point.
if (cdk.Token.isUnresolved(condition) || cdk.Token.isUnresolved(existing)) {
throw new Error(`multiple "${operator}" conditions cannot be merged if one of them contains an unresolved token`);
}

mergedConditions[operator] = { ...existing, ...condition };
});
return mergedConditions;
}
Expand Down
165 changes: 165 additions & 0 deletions packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
{
"Parameters": {
"PrincipalTag": {
"Type": "String",
"Default": "developer"
},
"AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3BucketEF5DD638": {
"Type": "String",
"Description": "S3 bucket for asset \"e02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57\""
},
"AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3VersionKey2EA0DB2E": {
"Type": "String",
"Description": "S3 key for asset version \"e02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57\""
},
"AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57ArtifactHash95B71D2D": {
"Type": "String",
"Description": "Artifact hash for asset \"e02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57\""
}
},
"Resources": {
"PrincipalTagCondition94CCB594": {
"Type": "Custom::AWSCDKCfnJson",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57",
"Arn"
]
},
"Value": {
"Fn::Join": [
"",
[
"{\"aws:PrincipalTag/",
{
"Ref": "PrincipalTag"
},
"\":\"true\"}"
]
]
}
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete"
},
"AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
]
},
"ManagedPolicyArns": [
{
"Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
]
}
},
"AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": {
"Ref": "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3BucketEF5DD638"
},
"S3Key": {
"Fn::Join": [
"",
[
{
"Fn::Select": [
0,
{
"Fn::Split": [
"||",
{
"Ref": "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3VersionKey2EA0DB2E"
}
]
}
]
},
{
"Fn::Select": [
1,
{
"Fn::Split": [
"||",
{
"Ref": "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3VersionKey2EA0DB2E"
}
]
}
]
}
]
]
}
},
"Timeout": 900,
"MemorySize": 128,
"Handler": "__entrypoint__.handler",
"Role": {
"Fn::GetAtt": [
"AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867",
"Arn"
]
},
"Runtime": "nodejs12.x"
},
"DependsOn": [
"AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867"
]
},
"MyRoleF48FFE04": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"Fn::GetAtt": [
"PrincipalTagCondition94CCB594",
"Value"
]
}
},
"Effect": "Allow",
"Principal": {
"AWS": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":iam::",
{
"Ref": "AWS::AccountId"
},
":root"
]
]
}
}
}
],
"Version": "2012-10-17"
}
}
}
}
}
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { App, CfnJson, CfnParameter, Construct, Stack } from '@aws-cdk/core';
import { AccountRootPrincipal, Role } from '../lib';

class MyStack extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id);

const tagName = new CfnParameter(this, 'PrincipalTag', { default: 'developer' });

const stringEquals = new CfnJson(this, 'PrincipalTagCondition', {
value: {
[`aws:PrincipalTag/${tagName.valueAsString}`]: 'true',
},
});

const principal = new AccountRootPrincipal().withConditions({
StringEquals: stringEquals,
});

new Role(this, 'MyRole', { assumedBy: principal });
}
}

const app = new App();
new MyStack(app, 'test-condition-with-ref');
app.synth();
43 changes: 41 additions & 2 deletions packages/@aws-cdk/aws-iam/test/policy-document.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import '@aws-cdk/assert/jest';
import { Lazy, Stack, Token } from '@aws-cdk/core';
import {
AccountPrincipal, Anyone, AnyPrincipal, ArnPrincipal, CanonicalUserPrincipal, CompositePrincipal, Effect,
FederatedPrincipal, IPrincipal, PolicyDocument, PolicyStatement, PrincipalPolicyFragment, ServicePrincipal,
AccountPrincipal, Anyone, AnyPrincipal, ArnPrincipal, CanonicalUserPrincipal, CompositePrincipal,
Effect, FederatedPrincipal, IPrincipal, PolicyDocument, PolicyStatement, PrincipalPolicyFragment, ServicePrincipal,
} from '../lib';

describe('IAM policy document', () => {
Expand Down Expand Up @@ -543,6 +543,45 @@ describe('IAM policy document', () => {
});
});

test('tokens can be used in conditions', () => {
// GIVEN
const stack = new Stack();
const statement = new PolicyStatement();

// WHEN
const p = new ArnPrincipal('arn:of:principal').withConditions({
StringEquals: Lazy.anyValue({ produce: () => ({ goo: 'zar' })}),
});

statement.addPrincipals(p);

// THEN
const resolved = stack.resolve(statement.toStatementJson());
expect(resolved).toEqual({
Condition: {
StringEquals: {
goo: 'zar',
},
},
Effect: 'Allow',
Principal: {
AWS: 'arn:of:principal',
},
});
});

test('conditions cannot be merged if they include tokens', () => {
const p = new FederatedPrincipal('fed', {
StringEquals: { foo: 'bar' },
}).withConditions({
StringEquals: Lazy.anyValue({ produce: () => ({ goo: 'zar' })}),
});

const statement = new PolicyStatement();

expect(() => statement.addPrincipals(p)).toThrow(/multiple "StringEquals" conditions cannot be merged if one of them contains an unresolved token/);
});

test('values passed to `withConditions` overwrite values from the wrapped principal ' +
'when keys conflict within an operator', () => {
const p = new FederatedPrincipal('fed', {
Expand Down
38 changes: 38 additions & 0 deletions packages/@aws-cdk/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -815,3 +815,41 @@ const stack = new Stack(app, 'StackName', {
```

By default, termination protection is disabled.

### CfnJson

`CfnJson` allows you to postpone the resolution of a JSON blob from
deployment-time. This is useful in cases where the CloudFormation JSON template
cannot express a certain value.

A common example is to use `CfnJson` in order to render a JSON map which needs
to use intrinsic functions in keys. Since JSON map keys must be strings, it is
impossible to use intrinsics in keys and `CfnJson` can help.

The following example defines an IAM role which can only be assumed by
principals that are tagged with a specific tag.

```ts
const tagParam = new CfnParameter(this, 'TagName');

const stringEquals = new CfnJson(this, 'ConditionJson', {
value: {
[`aws:PrincipalTag/${tagParam.valueAsString}`]: true
},
});

const principal = new AccountRootPrincipal().withConditions({
StringEquals: stringEquals,
});

new Role(this, 'MyRole', { assumedBy: principal });
```

**Explanation**: since in this example we pass the tag name through a parameter, it
can only be resolved during deployment. The resolved value can be represented in
the template through a `{ "Ref": "TagName" }`. However, since we want to use
this value inside a [`aws:PrincipalTag/TAG-NAME`](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-principaltag)
IAM operator, we need it in the *key* of a `StringEquals` condition. JSON keys
*must be* strings, so to circumvent this limitation, we use `CfnJson`
to "delay" the rendition of this template section to deploy-time. This means
that the value of `StringEquals` in the template will be `{ "Fn::GetAtt": [ "ConditionJson", "Value" ] }`, and will only "expand" to the operator we synthesized during deployment.
Loading

0 comments on commit 195cd40

Please sign in to comment.