packages/aws-cdk-lib/aws-lambda/lib/function-base.ts (414 lines of code) (raw):

import { createHash } from 'crypto'; import { Construct, Node } from 'constructs'; import { AliasOptions } from './alias'; import { Architecture } from './architecture'; import { EventInvokeConfig, EventInvokeConfigOptions } from './event-invoke-config'; import { IEventSource } from './event-source'; import { EventSourceMapping, EventSourceMappingOptions } from './event-source-mapping'; import { FunctionUrlAuthType, FunctionUrlOptions, FunctionUrl } from './function-url'; import { IVersion } from './lambda-version'; import { CfnPermission } from './lambda.generated'; import { Permission } from './permission'; import { addAlias, flatMap } from './util'; import * as cloudwatch from '../../aws-cloudwatch'; import * as ec2 from '../../aws-ec2'; import * as iam from '../../aws-iam'; import { Annotations, ArnFormat, IResource, Resource, Token, Stack, FeatureFlags } from '../../core'; import { ValidationError } from '../../core/lib/errors'; import { MethodMetadata } from '../../core/lib/metadata-resource'; import * as cxapi from '../../cx-api'; export interface IFunction extends IResource, ec2.IConnectable, iam.IGrantable { /** * The name of the function. * * @attribute */ readonly functionName: string; /** * The ARN of the function. * * @attribute */ readonly functionArn: string; /** * The IAM role associated with this function. */ readonly role?: iam.IRole; /** * Whether or not this Lambda function was bound to a VPC * * If this is is `false`, trying to access the `connections` object will fail. */ readonly isBoundToVpc: boolean; /** * The `$LATEST` version of this function. * * Note that this is reference to a non-specific AWS Lambda version, which * means the function this version refers to can return different results in * different invocations. * * To obtain a reference to an explicit version which references the current * function configuration, use `lambdaFunction.currentVersion` instead. */ readonly latestVersion: IVersion; /** * The construct node where permissions are attached. */ readonly permissionsNode: Node; /** * The system architectures compatible with this lambda function. */ readonly architecture: Architecture; /** * The ARN(s) to put into the resource field of the generated IAM policy for grantInvoke(). * * This property is for cdk modules to consume only. You should not need to use this property. * Instead, use grantInvoke() directly. */ readonly resourceArnsForGrantInvoke: string[]; /** * Adds an event source that maps to this AWS Lambda function. * @param id construct ID * @param options mapping options */ addEventSourceMapping(id: string, options: EventSourceMappingOptions): EventSourceMapping; /** * Adds a permission to the Lambda resource policy. * @param id The id for the permission construct * @param permission The permission to grant to this Lambda function. @see Permission for details. */ addPermission(id: string, permission: Permission): void; /** * Adds a statement to the IAM role assumed by the instance. */ addToRolePolicy(statement: iam.PolicyStatement): void; /** * Grant the given identity permissions to invoke this Lambda */ grantInvoke(identity: iam.IGrantable): iam.Grant; /** * Grant the given identity permissions to invoke the $LATEST version or * unqualified version of this Lambda */ grantInvokeLatestVersion(identity: iam.IGrantable): iam.Grant; /** * Grant the given identity permissions to invoke the given version of this Lambda */ grantInvokeVersion(identity: iam.IGrantable, version: IVersion): iam.Grant; /** * Grant the given identity permissions to invoke this Lambda Function URL */ grantInvokeUrl(identity: iam.IGrantable): iam.Grant; /** * Grant multiple principals the ability to invoke this Lambda via CompositePrincipal */ grantInvokeCompositePrincipal(compositePrincipal: iam.CompositePrincipal): iam.Grant[]; /** * Return the given named metric for this Lambda */ metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; /** * Metric for the Duration of this Lambda * * @default average over 5 minutes */ metricDuration(props?: cloudwatch.MetricOptions): cloudwatch.Metric; /** * Metric for the number of invocations of this Lambda * * @default sum over 5 minutes */ metricInvocations(props?: cloudwatch.MetricOptions): cloudwatch.Metric; /** * Metric for the number of throttled invocations of this Lambda * * @default sum over 5 minutes */ metricThrottles(props?: cloudwatch.MetricOptions): cloudwatch.Metric; /** * Adds an event source to this function. * * Event sources are implemented in the aws-cdk-lib/aws-lambda-event-sources module. * * The following example adds an SQS Queue as an event source: * ``` * import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; * myFunction.addEventSource(new SqsEventSource(myQueue)); * ``` */ addEventSource(source: IEventSource): void; /** * Configures options for asynchronous invocation. */ configureAsyncInvoke(options: EventInvokeConfigOptions): void; /** * Adds a url to this lambda function. */ addFunctionUrl(options?: FunctionUrlOptions): FunctionUrl; } /** * Represents a Lambda function defined outside of this stack. */ export interface FunctionAttributes { /** * The ARN of the Lambda function. * * Format: arn:<partition>:lambda:<region>:<account-id>:function:<function-name> */ readonly functionArn: string; /** * The IAM execution role associated with this function. * * If the role is not specified, any role-related operations will no-op. */ readonly role?: iam.IRole; /** * Id of the security group of this Lambda, if in a VPC. * * This needs to be given in order to support allowing connections * to this Lambda. * * @deprecated use `securityGroup` instead */ readonly securityGroupId?: string; /** * The security group of this Lambda, if in a VPC. * * This needs to be given in order to support allowing connections * to this Lambda. */ readonly securityGroup?: ec2.ISecurityGroup; /** * Setting this property informs the CDK that the imported function is in the same environment as the stack. * This affects certain behaviours such as, whether this function's permission can be modified. * When not configured, the CDK attempts to auto-determine this. For environment agnostic stacks, i.e., stacks * where the account is not specified with the `env` property, this is determined to be false. * * Set this to property *ONLY IF* the imported function is in the same account as the stack * it's imported in. * @default - depends: true, if the Stack is configured with an explicit `env` (account and region) and the account is the same as this function. * For environment-agnostic stacks this will default to `false`. */ readonly sameEnvironment?: boolean; /** * Setting this property informs the CDK that the imported function ALREADY HAS the necessary permissions * for what you are trying to do. When not configured, the CDK attempts to auto-determine whether or not * additional permissions are necessary on the function when grant APIs are used. If the CDK tried to add * permissions on an imported lambda, it will fail. * * Set this property *ONLY IF* you are committing to manage the imported function's permissions outside of * CDK. You are acknowledging that your CDK code alone will have insufficient permissions to access the * imported function. * * @default false */ readonly skipPermissions?: boolean; /** * The architecture of this Lambda Function (this is an optional attribute and defaults to X86_64). * @default - Architecture.X86_64 */ readonly architecture?: Architecture; } export abstract class FunctionBase extends Resource implements IFunction, ec2.IClientVpnConnectionHandler { /** * The principal this Lambda Function is running as */ public abstract readonly grantPrincipal: iam.IPrincipal; /** * The name of the function. */ public abstract readonly functionName: string; /** * The ARN fo the function. */ public abstract readonly functionArn: string; /** * The IAM role associated with this function. * * Undefined if the function was imported without a role. */ public abstract readonly role?: iam.IRole; /** * The construct node where permissions are attached. */ public abstract readonly permissionsNode: Node; /** * The architecture of this Lambda Function. */ public abstract readonly architecture: Architecture; /** * Whether the addPermission() call adds any permissions * * True for new Lambdas, false for version $LATEST and imported Lambdas * from different accounts. */ protected abstract readonly canCreatePermissions: boolean; /** * The ARN(s) to put into the resource field of the generated IAM policy for grantInvoke() */ public abstract readonly resourceArnsForGrantInvoke: string[]; /** * Whether the user decides to skip adding permissions. * The only use case is for cross-account, imported lambdas * where the user commits to modifying the permisssions * on the imported lambda outside CDK. * @internal */ protected readonly _skipPermissions?: boolean; /** * Actual connections object for this Lambda * * May be unset, in which case this Lambda is not configured use in a VPC. * @internal */ protected _connections?: ec2.Connections; private _latestVersion?: LatestVersion; /** * Flag to delay adding a warning message until current version is invoked. * @internal */ protected _warnIfCurrentVersionCalled: boolean = false; /** * Mapping of invocation principals to grants. Used to de-dupe `grantInvoke()` calls. * @internal */ protected _invocationGrants: Record<string, iam.Grant> = {}; /** * Mapping of function URL invocation principals to grants. Used to de-dupe `grantInvokeUrl()` calls. * @internal */ protected _functionUrlInvocationGrants: Record<string, iam.Grant> = {}; /** * The number of permissions added to this function * @internal */ private _policyCounter: number = 0; /** * A warning will be added to functions under the following conditions: * - permissions that include `lambda:InvokeFunction` are added to the unqualified function. * - function.currentVersion is invoked before or after the permission is created. * * This applies only to permissions on Lambda functions, not versions or aliases. * This function is overridden as a noOp for QualifiedFunctionBase. */ public considerWarningOnInvokeFunctionPermissions(scope: Construct, action: string) { const affectedPermissions = ['lambda:InvokeFunction', 'lambda:*', 'lambda:Invoke*']; if (affectedPermissions.includes(action)) { if (scope.node.tryFindChild('CurrentVersion')) { this.warnInvokeFunctionPermissions(scope); } else { this._warnIfCurrentVersionCalled = true; } } } protected warnInvokeFunctionPermissions(scope: Construct): void { Annotations.of(scope).addWarningV2('@aws-cdk/aws-lambda:addPermissionsToVersionOrAlias', [ "AWS Lambda has changed their authorization strategy, which may cause client invocations using the 'Qualifier' parameter of the lambda function to fail with Access Denied errors.", "If you are using a lambda Version or Alias, make sure to call 'grantInvoke' or 'addPermission' on the Version or Alias, not the underlying Function", 'See: https://github.com/aws/aws-cdk/issues/19273', ].join('\n')); } /** * Adds a permission to the Lambda resource policy. * @param id The id for the permission construct * @param permission The permission to grant to this Lambda function. @see Permission for details. */ public addPermission(id: string, permission: Permission) { if (!this.canCreatePermissions) { if (!this._skipPermissions) { Annotations.of(this).addWarningV2('UnclearLambdaEnvironment', `addPermission() has no effect on a Lambda Function with region=${this.env.region}, account=${this.env.account}, in a Stack with region=${Stack.of(this).region}, account=${Stack.of(this).account}. Suppress this warning if this is is intentional, or pass sameEnvironment=true to fromFunctionAttributes() if you would like to add the permissions.`); } return; } let principal = this.parsePermissionPrincipal(permission.principal); let { sourceArn, sourceAccount, principalOrgID } = this.validateConditionCombinations(permission.principal) ?? {}; const action = permission.action ?? 'lambda:InvokeFunction'; const scope = permission.scope ?? this; this.considerWarningOnInvokeFunctionPermissions(scope, action); new CfnPermission(scope, id, { action, principal, functionName: this.functionArn, eventSourceToken: permission.eventSourceToken, sourceAccount: permission.sourceAccount ?? sourceAccount, sourceArn: permission.sourceArn ?? sourceArn, principalOrgId: permission.organizationId ?? principalOrgID, functionUrlAuthType: permission.functionUrlAuthType, }); } /** * Adds a statement to the IAM role assumed by the instance. */ public addToRolePolicy(statement: iam.PolicyStatement) { const useCreateNewPolicies = FeatureFlags.of(this).isEnabled(cxapi.LAMBDA_CREATE_NEW_POLICIES_WITH_ADDTOROLEPOLICY); if (!this.role) { return; } if (useCreateNewPolicies) { const policyToAdd = new iam.Policy(this, `inlinePolicyAddedToExecutionRole-${this._policyCounter++}`, { statements: [statement], }); this.role.attachInlinePolicy(policyToAdd); } else { this.role.addToPrincipalPolicy(statement); } } /** * Access the Connections object * * Will fail if not a VPC-enabled Lambda Function */ public get connections(): ec2.Connections { if (!this._connections) { // eslint-disable-next-line max-len throw new ValidationError('Only VPC-associated Lambda Functions have security groups to manage. Supply the "vpc" parameter when creating the Lambda, or "securityGroupId" when importing it.', this); } return this._connections; } public get latestVersion(): IVersion { if (!this._latestVersion) { this._latestVersion = new LatestVersion(this); } return this._latestVersion; } /** * Whether or not this Lambda function was bound to a VPC * * If this is is `false`, trying to access the `connections` object will fail. */ public get isBoundToVpc(): boolean { return !!this._connections; } public addEventSourceMapping(id: string, options: EventSourceMappingOptions): EventSourceMapping { return new EventSourceMapping(this, id, { target: this, ...options, }); } /** * Grant the given identity permissions to invoke this Lambda */ public grantInvoke(grantee: iam.IGrantable): iam.Grant { const hash = createHash('sha256') .update(JSON.stringify({ principal: grantee.grantPrincipal.toString(), conditions: grantee.grantPrincipal.policyFragment.conditions, }), 'utf8') .digest('base64'); const identifier = `Invoke${hash}`; // Memoize the result so subsequent grantInvoke() calls are idempotent let grant = this._invocationGrants[identifier]; if (!grant) { grant = this.grant(grantee, identifier, 'lambda:InvokeFunction', this.resourceArnsForGrantInvoke); this._invocationGrants[identifier] = grant; } return grant; } /** * Grant the given identity permissions to invoke the $LATEST version or * unqualified version of this Lambda */ public grantInvokeLatestVersion(grantee: iam.IGrantable): iam.Grant { return this.grantInvokeVersion(grantee, this.latestVersion); } /** * Grant the given identity permissions to invoke the given version of this Lambda */ public grantInvokeVersion(grantee: iam.IGrantable, version: IVersion): iam.Grant { const hash = createHash('sha256') .update(JSON.stringify({ principal: grantee.grantPrincipal.toString(), conditions: grantee.grantPrincipal.policyFragment.conditions, version: version.version, }), 'utf8') .digest('base64'); const identifier = `Invoke${hash}`; // Memoize the result so subsequent grantInvoke() calls are idempotent let grant = this._invocationGrants[identifier]; if (!grant) { let resouceArns = [`${this.functionArn}:${version.version}`]; if (version == this.latestVersion) { resouceArns.push(this.functionArn); } grant = this.grant(grantee, identifier, 'lambda:InvokeFunction', resouceArns); this._invocationGrants[identifier] = grant; } return grant; } /** * Grant the given identity permissions to invoke this Lambda Function URL */ public grantInvokeUrl(grantee: iam.IGrantable): iam.Grant { const identifier = `InvokeFunctionUrl${grantee.grantPrincipal}`; // calls the .toString() of the principal // Memoize the result so subsequent grantInvoke() calls are idempotent let grant = this._functionUrlInvocationGrants[identifier]; if (!grant) { grant = this.grant(grantee, identifier, 'lambda:InvokeFunctionUrl', [this.functionArn], { functionUrlAuthType: FunctionUrlAuthType.AWS_IAM, }); this._functionUrlInvocationGrants[identifier] = grant; } return grant; } /** * Grant multiple principals the ability to invoke this Lambda via CompositePrincipal */ public grantInvokeCompositePrincipal(compositePrincipal: iam.CompositePrincipal): iam.Grant[] { return compositePrincipal.principals.map((principal) => this.grantInvoke(principal)); } public addEventSource(source: IEventSource) { source.bind(this); } public configureAsyncInvoke(options: EventInvokeConfigOptions): void { if (this.node.tryFindChild('EventInvokeConfig') !== undefined) { throw new ValidationError(`An EventInvokeConfig has already been configured for the function at ${this.node.path}`, this); } new EventInvokeConfig(this, 'EventInvokeConfig', { function: this, ...options, }); } public addFunctionUrl(options?: FunctionUrlOptions): FunctionUrl { return new FunctionUrl(this, 'FunctionUrl', { function: this, ...options, }); } /** * Returns the construct tree node that corresponds to the lambda function. * For use internally for constructs, when the tree is set up in non-standard ways. Ex: SingletonFunction. * @internal */ protected _functionNode(): Node { return this.node; } /** * Given the function arn, check if the account id matches this account * * Function ARNs look like this: * * arn:aws:lambda:region:account-id:function:function-name * * ..which means that in order to extract the `account-id` component from the ARN, we can * split the ARN using ":" and select the component in index 4. * * @returns true if account id of function matches the account specified on the stack, false otherwise. * * @internal */ protected _isStackAccount(): boolean { if (Token.isUnresolved(this.stack.account) || Token.isUnresolved(this.functionArn)) { return false; } return this.stack.splitArn(this.functionArn, ArnFormat.SLASH_RESOURCE_NAME).account === this.stack.account; } private grant( grantee: iam.IGrantable, identifier:string, action: string, resourceArns: string[], permissionOverrides?: Partial<Permission>, ): iam.Grant { const grant = iam.Grant.addToPrincipalOrResource({ grantee, actions: [action], resourceArns, // Fake resource-like object on which to call addToResourcePolicy(), which actually // calls addPermission() resource: { addToResourcePolicy: (_statement) => { // Couldn't add permissions to the principal, so add them locally. this.addPermission(identifier, { principal: grantee.grantPrincipal!, action: action, ...permissionOverrides, }); const permissionNode = this._functionNode().tryFindChild(identifier); if (!permissionNode && !this._skipPermissions) { throw new ValidationError('Cannot modify permission to lambda function. Function is either imported or $LATEST version.\n' + 'If the function is imported from the same account use `fromFunctionAttributes()` API with the `sameEnvironment` flag.\n' + 'If the function is imported from a different account and already has the correct permissions use `fromFunctionAttributes()` API with the `skipPermissions` flag.', this); } return { statementAdded: true, policyDependable: permissionNode }; }, node: this.node, stack: this.stack, env: this.env, applyRemovalPolicy: x => this.applyRemovalPolicy(x), }, }); return grant; } /** * Translate IPrincipal to something we can pass to AWS::Lambda::Permissions * * Do some nasty things because `Permission` supports a subset of what the * full IAM principal language supports, and we may not be able to parse strings * outright because they may be tokens. * * Try to recognize some specific Principal classes first, then try a generic * fallback. */ private parsePermissionPrincipal(principal: iam.IPrincipal | { readonly wrapped: iam.IPrincipal }) { // Try some specific common classes first. // use duck-typing, not instance of if ('wrapped' in principal) { // eslint-disable-next-line dot-notation principal = principal['wrapped']; } if ('accountId' in principal) { return (principal as iam.AccountPrincipal).accountId; } if ('service' in principal) { return (principal as iam.ServicePrincipal).service; } if ('arn' in principal) { return (principal as iam.ArnPrincipal).arn; } const stringEquals = matchSingleKey('StringEquals', principal.policyFragment.conditions); if (stringEquals) { const orgId = matchSingleKey('aws:PrincipalOrgID', stringEquals); if (orgId) { // we will move the organization id to the `principalOrgId` property of `Permissions`. return '*'; } } // Try a best-effort approach to support simple principals that are not any of the predefined // classes, but are simple enough that they will fit into the Permission model. Main target // here: imported Roles, Users, Groups. // // The principal cannot have conditions and must have a single { AWS: [arn] } entry. const json = principal.policyFragment.principalJson; if (Object.keys(principal.policyFragment.conditions).length === 0 && json.AWS) { if (typeof json.AWS === 'string') { return json.AWS; } if (Array.isArray(json.AWS) && json.AWS.length === 1 && typeof json.AWS[0] === 'string') { return json.AWS[0]; } } throw new ValidationError(`Invalid principal type for Lambda permission statement: ${principal.constructor.name}. ` + 'Supported: AccountPrincipal, ArnPrincipal, ServicePrincipal, OrganizationPrincipal', this); /** * Returns the value at the key if the object contains the key and nothing else. Otherwise, * returns undefined. */ function matchSingleKey(key: string, obj: Record<string, any>): any | undefined { if (Object.keys(obj).length !== 1) { return undefined; } return obj[key]; } } private validateConditionCombinations(principal: iam.IPrincipal): { sourceArn: string | undefined; sourceAccount: string | undefined; principalOrgID: string | undefined; } | undefined { const conditions = this.validateConditions(principal); if (!conditions) { return undefined; } const sourceArn = requireString(requireObject(conditions.ArnLike)?.['aws:SourceArn']); const sourceAccount = requireString(requireObject(conditions.StringEquals)?.['aws:SourceAccount']); const principalOrgID = requireString(requireObject(conditions.StringEquals)?.['aws:PrincipalOrgID']); // PrincipalOrgID cannot be combined with any other conditions if (principalOrgID && (sourceArn || sourceAccount)) { throw new ValidationError('PrincipalWithConditions had unsupported condition combinations for Lambda permission statement: principalOrgID cannot be set with other conditions.', this); } return { sourceArn, sourceAccount, principalOrgID, }; } private validateConditions(principal: iam.IPrincipal): iam.Conditions | undefined { if (this.isPrincipalWithConditions(principal)) { const conditions: iam.Conditions = principal.policyFragment.conditions; const conditionPairs = flatMap( Object.entries(conditions), ([operator, conditionObjs]) => Object.keys(conditionObjs as object).map(key => { return { operator, key }; }), ); // These are all the supported conditions. Some combinations are not supported, // like only 'aws:SourceArn' or 'aws:PrincipalOrgID' and 'aws:SourceAccount'. // These will be validated through `this.validateConditionCombinations`. const supportedPrincipalConditions = [{ operator: 'ArnLike', key: 'aws:SourceArn', }, { operator: 'StringEquals', key: 'aws:SourceAccount', }, { operator: 'StringEquals', key: 'aws:PrincipalOrgID', }]; const unsupportedConditions = conditionPairs.filter( (condition) => !supportedPrincipalConditions.some( (supportedCondition) => supportedCondition.operator === condition.operator && supportedCondition.key === condition.key, ), ); if (unsupportedConditions.length == 0) { return conditions; } else { throw new ValidationError(`PrincipalWithConditions had unsupported conditions for Lambda permission statement: ${JSON.stringify(unsupportedConditions)}. ` + `Supported operator/condition pairs: ${JSON.stringify(supportedPrincipalConditions)}`, this); } } return undefined; } private isPrincipalWithConditions(principal: iam.IPrincipal): boolean { return Object.keys(principal.policyFragment.conditions).length > 0; } } export abstract class QualifiedFunctionBase extends FunctionBase { /** The underlying `IFunction` */ public abstract readonly lambda: IFunction; public readonly permissionsNode = this.node; /** * The qualifier of the version or alias of this function. * A qualifier is the identifier that's appended to a version or alias ARN. * @see https://docs.aws.amazon.com/lambda/latest/dg/API_GetFunctionConfiguration.html#API_GetFunctionConfiguration_RequestParameters */ protected abstract readonly qualifier: string; public get latestVersion() { return this.lambda.latestVersion; } public get resourceArnsForGrantInvoke() { return [this.functionArn]; } public configureAsyncInvoke(options: EventInvokeConfigOptions): void { if (this.node.tryFindChild('EventInvokeConfig') !== undefined) { throw new ValidationError(`An EventInvokeConfig has already been configured for the qualified function at ${this.node.path}`, this); } new EventInvokeConfig(this, 'EventInvokeConfig', { function: this.lambda, qualifier: this.qualifier, ...options, }); } public considerWarningOnInvokeFunctionPermissions(_scope: Construct, _action: string): void { // noOp return; } } /** * The $LATEST version of a function, useful when attempting to create aliases. */ class LatestVersion extends FunctionBase implements IVersion { public readonly lambda: IFunction; public readonly version = '$LATEST'; public readonly permissionsNode = this.node; protected readonly canCreatePermissions = false; constructor(lambda: FunctionBase) { super(lambda, '$LATEST'); this.lambda = lambda; } public get functionArn() { return `${this.lambda.functionArn}:${this.version}`; } public get functionName() { return `${this.lambda.functionName}:${this.version}`; } public get architecture() { return this.lambda.architecture; } public get grantPrincipal() { return this.lambda.grantPrincipal; } public get latestVersion() { return this; } public get role() { return this.lambda.role; } public get edgeArn(): never { throw new ValidationError('$LATEST function version cannot be used for Lambda@Edge', this); } public get resourceArnsForGrantInvoke() { return [this.functionArn]; } @MethodMetadata() public addAlias(aliasName: string, options: AliasOptions = {}) { return addAlias(this, this, aliasName, options); } } function requireObject(x: unknown): Record<string, unknown> | undefined { return x && typeof x === 'object' && !Array.isArray(x) ? x as any : undefined; } function requireString(x: unknown): string | undefined { return x && typeof x === 'string' ? x : undefined; }