Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(stepfunctions): support cross-account task invocations #23012

Merged
merged 15 commits into from
Dec 6, 2022
Merged
22 changes: 22 additions & 0 deletions packages/@aws-cdk/aws-stepfunctions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,28 @@ const definition = sfn.Chain
// ...
```

## Task Credentials

Tasks are executed using the State Machine's execution role. In some cases, e.g. cross-account access, an IAM role can be assumed by the State Machine's execution role to provide access to the resource.
This can be achieved by providing the optional `credentials` property which allows using a literal `roleArn` or a json expression to resolve the `roleArn` at runtime.

```ts
import * as lambda from '@aws-cdk/aws-lambda';

declare const submitLambda: lambda.Function;

const submitJob = new tasks.LambdaInvoke(this, 'Submit Job', {
lambdaFunction: submitLambda,
outputPath: '$.Payload',
credentials: {
// literal role
roleArn: 'arn:aws:iam::123456789012:role/role-to-invoke-lambda',
// or use a json expression role
// roleArn: sfn.JsonPath.stringAt('$.RoleArn'),
},
});
```

## State Machine Fragments

It is possible to define reusable (or abstracted) mini-state machines by
Expand Down
38 changes: 38 additions & 0 deletions packages/@aws-cdk/aws-stepfunctions/lib/states/task-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as iam from '@aws-cdk/aws-iam';
import * as cdk from '@aws-cdk/core';
import { Construct } from 'constructs';
import { Chain } from '../chain';
import { FieldUtils, JsonPath } from '../fields';
import { StateGraph } from '../state-graph';
import { CatchProps, IChainable, INextable, RetryProps } from '../types';
import { renderJsonPath, State } from './state';
Expand Down Expand Up @@ -91,6 +92,16 @@ export interface TaskStateBaseProps {
*
*/
readonly integrationPattern?: IntegrationPattern;

/**
* Credentials for an IAM Role that the State Machine assumes for executing the task.
* This enables cross-account resource invocations.
*
* @see https://docs.aws.amazon.com/step-functions/latest/dg/concepts-access-cross-acct-resources.html
*
* @default - None (Task is executed using the State Machine's execution role)
*/
readonly credentials?: Credentials;
}

/**
Expand All @@ -112,12 +123,14 @@ export abstract class TaskStateBase extends State implements INextable {

private readonly timeout?: cdk.Duration;
private readonly heartbeat?: cdk.Duration;
private readonly credentials?: Credentials;

constructor(scope: Construct, id: string, props: TaskStateBaseProps) {
super(scope, id, props);
this.endStates = [this];
this.timeout = props.timeout;
this.heartbeat = props.heartbeat;
this.credentials = props.credentials;
}

/**
Expand Down Expand Up @@ -263,6 +276,14 @@ export abstract class TaskStateBase extends State implements INextable {
for (const policyStatement of this.taskPolicies || []) {
graph.registerPolicyStatement(policyStatement);
}
if (this.credentials) {
const resource = JsonPath.isEncodedJsonPath(this.credentials.roleArn) ? '*' : this.credentials.roleArn;
graph.registerPolicyStatement(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['sts:AssumeRole'],
resources: [resource],
}));
}
}

/**
Expand All @@ -277,6 +298,10 @@ export abstract class TaskStateBase extends State implements INextable {
return this.metric(prefix + suffix, props);
}

private renderCredentials() {
return this.credentials ? FieldUtils.renderObject({ Credentials: { RoleArn: this.credentials.roleArn } }) : undefined;
}

private renderTaskBase() {
return {
Type: 'Task',
Expand All @@ -287,6 +312,7 @@ export abstract class TaskStateBase extends State implements INextable {
OutputPath: renderJsonPath(this.outputPath),
ResultPath: renderJsonPath(this.resultPath),
...this.renderResultSelector(),
...this.renderCredentials(),
};
}
}
Expand Down Expand Up @@ -347,4 +373,16 @@ export enum IntegrationPattern {
* @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html#connect-wait-token
*/
WAIT_FOR_TASK_TOKEN = 'WAIT_FOR_TASK_TOKEN'
}

/**
* Specifies a target role assumed by the State Machine's execution role for invoking the task's resource.
*/
export interface Credentials {

/**
* The ARN of the IAM role to be assumed.
* Either a fixed value or a JSONPath expression can be used.
*/
readonly roleArn: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be much nicer if there was a concrete type denoting json path e.g.

role: iam.IRole | JsonPath;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We wouldn't want to add this depth to these props just to get a roleArn. Typically, we pass IRole, but I see what you're trying to do here so I suggest you take the example of Schedule and do something like what they did there with an enum like class.

Copy link
Contributor Author

@humanzz humanzz Nov 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I 100% follow

  1. When you say "this depth", do you mean the iam.IRole | JsonPath bit, or do you mean the hierarchy of of the prop Credentials.roleArn? If hierarchy, i.e. whether roleArn should be top level or not, the only reason I didn't do that, is I thought maybe in the future their can be more attributes in the Credentials object e.g. session name, etc.
  2. The role - rather than Credentials - is what really might benefit from different modelling similar to Schedule.

If we go for modelling a Role class here it would be something along the lines of

class Role {

public static jsonExpression(expression: string): Role

public static role(role: iam.Role): Role
}

The other thing is, I don't think there's a strong type for json expressions (to the best of my knowledge)

Copy link
Contributor Author

@humanzz humanzz Nov 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I introduced a new change to introduce TaskRole (inspired by Schedule as you suggested)

}
29 changes: 29 additions & 0 deletions packages/@aws-cdk/aws-stepfunctions/test/private/fake-task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as iam from '@aws-cdk/aws-iam';
import * as constructs from 'constructs';
import * as sfn from '../../lib';

export interface FakeTaskProps extends sfn.TaskStateBaseProps {
readonly metrics?: sfn.TaskMetricsConfig;
}

export class FakeTask extends sfn.TaskStateBase {
protected readonly taskMetrics?: sfn.TaskMetricsConfig;
protected readonly taskPolicies?: iam.PolicyStatement[];

constructor(scope: constructs.Construct, id: string, props: FakeTaskProps = {}) {
super(scope, id, props);
this.taskMetrics = props.metrics;
}

/**
* @internal
*/
protected _renderTask(): any {
return {
Resource: 'my-resource',
Parameters: sfn.FieldUtils.renderObject({
MyParameter: 'myParameter',
}),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ import * as sfn from '../../lib';
*/
export function render(stack: cdk.Stack, definition: sfn.IChainable) {
return stack.resolve(new sfn.StateGraph(definition.startState, 'Test Graph').toGraphJson());
}
}

export function renderGraph(definition: sfn.IChainable) {
return render(new cdk.Stack(), definition);
}
69 changes: 69 additions & 0 deletions packages/@aws-cdk/aws-stepfunctions/test/state-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as logs from '@aws-cdk/aws-logs';
import * as s3 from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
import * as stepfunctions from '../lib';
import { FakeTask } from './private/fake-task';

describe('State Machine', () => {
test('Instantiate Default State Machine', () => {
Expand Down Expand Up @@ -278,6 +279,74 @@ describe('State Machine', () => {
});
});

test('Instantiate a State Machine with a task assuming a literal roleArn', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add a test using fromRoleName using a stack with a different
account from the state machine? I want to make sure it builds the arn/reference
correctly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@humanzz I think this is still unresolved.

Copy link
Contributor Author

@humanzz humanzz Dec 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've misunderstood your intention - twice now :)
The last change breaks this test case into 2; one for cross-account role assumption, and one for same-account role assumption.

For integ tests, I wasn't sure how the cross-account behaviour can be tested i.e. can we create stacks in different test accounts, so I used fromRoleArn there.

// GIVEN
const stack = new cdk.Stack();

// WHEN
new stepfunctions.StateMachine(stack, 'MyStateMachine', {
definition: new FakeTask(stack, 'fakeTask', { credentials: { roleArn: 'arn:aws:iam::123456789012:role/example-role' } }),
});

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::StepFunctions::StateMachine', {
DefinitionString: '{"StartAt":"fakeTask","States":{"fakeTask":{"End":true,"Type":"Task","Credentials":{"RoleArn":"arn:aws:iam::123456789012:role/example-role"},"Resource":"my-resource","Parameters":{"MyParameter":"myParameter"}}}}',
});

Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [
{
Effect: 'Allow',
Action: 'sts:AssumeRole',
Resource: 'arn:aws:iam::123456789012:role/example-role',
},
],
Version: '2012-10-17',
},
PolicyName: 'MyStateMachineRoleDefaultPolicyE468EB18',
Roles: [
{
Ref: 'MyStateMachineRoleD59FFEBC',
},
],
});
});

test('Instantiate a State Machine with a task assuming a JSONPath roleArn', () => {
// GIVEN
const stack = new cdk.Stack();

// WHEN
new stepfunctions.StateMachine(stack, 'MyStateMachine', {
definition: new FakeTask(stack, 'fakeTask', { credentials: { roleArn: stepfunctions.JsonPath.stringAt('$.RoleArn') } }),
});

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::StepFunctions::StateMachine', {
DefinitionString: '{"StartAt":"fakeTask","States":{"fakeTask":{"End":true,"Type":"Task","Credentials":{"RoleArn.$":"$.RoleArn"},"Resource":"my-resource","Parameters":{"MyParameter":"myParameter"}}}}',
});

Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [
{
Effect: 'Allow',
Action: 'sts:AssumeRole',
Resource: '*',
},
],
Version: '2012-10-17',
},
PolicyName: 'MyStateMachineRoleDefaultPolicyE468EB18',
Roles: [
{
Ref: 'MyStateMachineRoleD59FFEBC',
},
],
});
});

describe('StateMachine.fromStateMachineArn()', () => {
let stack: cdk.Stack;

Expand Down
Loading