Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/aws-cdk-lib/aws-iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,8 +777,42 @@ const provider = new iam.OpenIdConnectProvider(this, 'MyProvider', {
clientIds: [ 'myclient1', 'myclient2' ],
});
const principal = new iam.OpenIdConnectPrincipal(provider);

```
### Custom Lambda Role for OIDC Providers

By default, the `OpenIdConnectProvider` creates its own IAM role for the underlying Lambda function with the required permissions. You can optionally provide your own role:

```ts
const customRole = new iam.Role(this, 'CustomOidcRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});

// You must add the required permissions manually
customRole.addToPrincipalPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
resources: ['*'],
actions: [
'iam:CreateOpenIDConnectProvider',
'iam:DeleteOpenIDConnectProvider',
'iam:UpdateOpenIDConnectProviderThumbprint',
'iam:AddClientIDToOpenIDConnectProvider',
'iam:RemoveClientIDFromOpenIDConnectProvider',
],
}));

const provider = new iam.OpenIdConnectProvider(this, 'MyProvider', {
url: 'https://openid/connect',
clientIds: ['myclient1', 'myclient2'],
role: customRole,
});
```

**Important**: When providing a custom role, you are responsible for ensuring it has all the necessary permissions for the OIDC provider to function correctly.

**Note**: This only applies to `OpenIdConnectProvider`. The newer `OidcProviderNative` uses native CloudFormation and does not create a Lambda function.


## SAML provider

An IAM SAML 2.0 identity provider is an entity in IAM that describes an external
Expand Down
17 changes: 14 additions & 3 deletions packages/aws-cdk-lib/aws-iam/lib/oidc-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Construct } from 'constructs';
import type { IRole } from './role';
import {
Arn,
CustomResource,
Expand All @@ -11,7 +12,6 @@ import { addConstructMetadata } from '../../core/lib/metadata-resource';
import { propertyInjectable } from '../../core/lib/prop-injectable';
import { OidcProvider } from '../../custom-resource-handlers/dist/aws-iam/oidc-provider.generated';
import { IAM_OIDC_REJECT_UNAUTHORIZED_CONNECTIONS } from '../../cx-api';

const RESOURCE_TYPE = 'Custom::AWSCDKOpenIdConnectProvider';

/**
Expand Down Expand Up @@ -87,6 +87,16 @@ export interface OpenIdConnectProviderProps {
* provider's server as described in https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html
*/
readonly thumbprints?: string[];

/**
* An optional user provided IAM role to be used for the underlying provider's lambda function.
*
* **Important**: When providing a custom role, you must ensure it has all the
* necessary permissions for the custom resource provider to function correctly.
*
* @default - No role, will be created automatically with the required permissions.
*/
readonly role?: IRole;
}

/**
Expand Down Expand Up @@ -150,7 +160,7 @@ export class OpenIdConnectProvider extends Resource implements IOpenIdConnectPro

const rejectUnauthorized = FeatureFlags.of(this).isEnabled(IAM_OIDC_REJECT_UNAUTHORIZED_CONNECTIONS) ?? false;

const provider = this.getOrCreateProvider();
const provider = this.getOrCreateProvider(props.role);
const resource = new CustomResource(this, 'Resource', {
resourceType: RESOURCE_TYPE,
serviceToken: provider.serviceToken,
Expand All @@ -172,7 +182,7 @@ export class OpenIdConnectProvider extends Resource implements IOpenIdConnectPro
this.openIdConnectProviderthumbprints = Token.asString(resource.getAtt('Thumbprints'));
}

private getOrCreateProvider() {
private getOrCreateProvider(role?: IRole) {
return OidcProvider.getOrCreateProvider(this, RESOURCE_TYPE, {
policyStatements: [
{
Expand All @@ -187,6 +197,7 @@ export class OpenIdConnectProvider extends Resource implements IOpenIdConnectPro
],
},
],
role,
});
}
}
19 changes: 19 additions & 0 deletions packages/aws-cdk-lib/aws-iam/test/oidc-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ describe('OpenIdConnectProvider resource', () => {
ThumbprintList: ['thumb1'],
});
});

test('custom role for internal lambda can be specified', () => {
// GIVEN
const stack = new Stack();
const role = new iam.Role(stack, 'MyRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});

// WHEN
new iam.OpenIdConnectProvider(stack, 'MyProvider', {
url: 'https://openid-endpoint',
role: role,
});

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', {
Role: stack.resolve(role.roleArn),
});
});
});

describe('custom resource provider infrastructure', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export abstract class CustomResourceProviderBase extends Construct {
private _codeHash?: string;
private policyStatements?: any[];
private role?: CfnResource;
private customRole?: any;

/**
* The ARN of the provider's AWS Lambda function which should be used as the `serviceToken` when defining a custom
Expand All @@ -74,57 +75,62 @@ export abstract class CustomResourceProviderBase extends Construct {
throw new ValidationError(`cannot find ${props.codeDirectory}/index.js`, this);
}

if (props.policyStatements) {
for (const statement of props.policyStatements) {
this.addToRolePolicy(statement);
}
}

const { code, codeHandler, metadata } = this.createCodePropAndMetadata(props, stack);

const config = getPrecreatedRoleConfig(this, `${this.node.path}/Role`);
const assumeRolePolicyDoc = [{ Action: 'sts:AssumeRole', Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' } }];
const managedPolicyArn = 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole';

// need to initialize this attribute, but there should never be an instance
// where config.enabled=true && config.preventSynthesis=true
this.roleArn = '';
if (config.enabled) {
// gives policyStatements a chance to resolve
this.node.addValidation({
validate: () => {
PolicySynthesizer.getOrCreate(this).addRole(`${this.node.path}/Role`, {
missing: !config.precreatedRoleName,
roleName: config.precreatedRoleName ?? id+'Role',
managedPolicies: [{ managedPolicyArn: managedPolicyArn }],
policyStatements: this.policyStatements ?? [],
assumeRolePolicy: assumeRolePolicyDoc as any,
});
return [];
},
});
this.roleArn = Stack.of(this).formatArn({
region: '',
service: 'iam',
resource: 'role',
resourceName: config.precreatedRoleName,
});
}
if (!config.preventSynthesis) {
this.role = new CfnResource(this, 'Role', {
type: 'AWS::IAM::Role',
properties: {
AssumeRolePolicyDocument: {
Version: '2012-10-17',
Statement: assumeRolePolicyDoc,
if (props.role) {
this.customRole = props.role;
this.roleArn = props.role.roleArn;
} else {
if (props.policyStatements) {
for (const statement of props.policyStatements) {
this.addToRolePolicy(statement);
}
}

const config = getPrecreatedRoleConfig(this, `${this.node.path}/Role`);
const assumeRolePolicyDoc = [{ Action: 'sts:AssumeRole', Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' } }];
const managedPolicyArn = 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole';

// need to initialize this attribute, but there should never be an instance
// where config.enabled=true && config.preventSynthesis=true
this.roleArn = '';
if (config.enabled) {
// gives policyStatements a chance to resolve
this.node.addValidation({
validate: () => {
PolicySynthesizer.getOrCreate(this).addRole(`${this.node.path}/Role`, {
missing: !config.precreatedRoleName,
roleName: config.precreatedRoleName ?? id+'Role',
managedPolicies: [{ managedPolicyArn: managedPolicyArn }],
policyStatements: this.policyStatements ?? [],
assumeRolePolicy: assumeRolePolicyDoc as any,
});
return [];
},
ManagedPolicyArns: [
{ 'Fn::Sub': managedPolicyArn },
],
Policies: Lazy.any({ produce: () => this.renderPolicies() }),
},
});
this.roleArn = Token.asString(this.role.getAtt('Arn'));
});
this.roleArn = Stack.of(this).formatArn({
region: '',
service: 'iam',
resource: 'role',
resourceName: config.precreatedRoleName,
});
}
if (!config.preventSynthesis) {
this.role = new CfnResource(this, 'Role', {
type: 'AWS::IAM::Role',
properties: {
AssumeRolePolicyDocument: {
Version: '2012-10-17',
Statement: assumeRolePolicyDoc,
},
ManagedPolicyArns: [
{ 'Fn::Sub': managedPolicyArn },
],
Policies: Lazy.any({ produce: () => this.renderPolicies() }),
},
});
this.roleArn = Token.asString(this.role.getAtt('Arn'));
}
}

const timeout = props.timeout ?? Duration.minutes(15);
Expand Down Expand Up @@ -173,6 +179,9 @@ export abstract class CustomResourceProviderBase extends Construct {
* });
*/
public addToRolePolicy(statement: any): void {
if (this.customRole) {
throw new ValidationError('Cannot call addToRolePolicy() when using a custom role. Add the required policies to your role directly.', this);
}
if (!this.policyStatements) {
this.policyStatements = [];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// import { IRole } from '../../../aws-iam';
import { Duration } from '../duration';
import { Size } from '../size';

Expand Down Expand Up @@ -69,4 +70,14 @@ export interface CustomResourceProviderOptions {
* @default - No description.
*/
readonly description?: string;

/**
* An optional user provided IAM role to be used for the provider's lambda function.
*
* **Important**: When providing a custom role, you must ensure it has all the
* necessary permissions for the custom resource provider to function correctly.
*
* @default - No role, will be created automatically with the required permissions.
*/
readonly role?: any;
}
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,54 @@ describe('custom resource provider', () => {
}]);
});

test('custom role can be provided', () => {
// GIVEN
const stack = new Stack();
const customRole = {
roleArn: 'arn:aws:iam::123456789012:role/MyCustomRole',
};

// WHEN
const provider = CustomResourceProvider.getOrCreateProvider(stack, 'Custom:MyResourceType', {
codeDirectory: TEST_HANDLER,
runtime: DEFAULT_PROVIDER_RUNTIME,
role: customRole,
});

// THEN
expect(provider.roleArn).toEqual('arn:aws:iam::123456789012:role/MyCustomRole');

const template = toCloudFormation(stack);

// No IAM role should be created
const resourceTypes = Object.values(template.Resources).map((r: any) => r.Type);
expect(resourceTypes).not.toContain('AWS::IAM::Role');

// Lambda should use the custom role ARN
const lambda = template.Resources.CustomMyResourceTypeCustomResourceProviderHandler29FBDD2A;
expect(lambda.Properties.Role).toEqual('arn:aws:iam::123456789012:role/MyCustomRole');
});

test('addToRolePolicy throws error when custom role is provided', () => {
// GIVEN
const stack = new Stack();
const customRole = {
roleArn: 'arn:aws:iam::123456789012:role/MyCustomRole',
};

// WHEN
const provider = CustomResourceProvider.getOrCreateProvider(stack, 'Custom:MyResourceType', {
codeDirectory: TEST_HANDLER,
runtime: DEFAULT_PROVIDER_RUNTIME,
role: customRole,
});

// THEN
expect(() => {
provider.addToRolePolicy({ statement3: 456 });
}).toThrow('Cannot call addToRolePolicy() when using a custom role. Add the required policies to your role directly.');
});

test('memorySize, timeout and description', () => {
// GIVEN
const stack = new Stack();
Expand Down
Loading
Loading