diff --git a/.changeset/core-shared-execution-role.md b/.changeset/core-shared-execution-role.md new file mode 100644 index 00000000..12773f7d --- /dev/null +++ b/.changeset/core-shared-execution-role.md @@ -0,0 +1,18 @@ +--- +"@aws-blocks/core": patch +--- + +feat(core): add a shared Blocks execution role and `Scope.executionRole` getter + +The Blocks stack/backend now provisions one explicit IAM role (with +`AWSLambdaBasicExecutionRole` attached) that the handler assumes, and exposes it +as `executionRole`. A new `Scope.executionRole` getter resolves the role from +any Building Block. Additive and non-breaking: the same handler is created, now +backed by an explicit role instead of an auto-generated one, with block grants +sitting on the role's default policy exactly as before. + +Migration note: on an existing deployed stack, upgrading replaces the Lambda +execution role — CloudFormation deletes the old auto-generated role and creates +the new `BlocksRole`. This is runtime-equivalent (the same grants re-attach to +the new role) and needs no action, but a change-set diff will show a role +delete+create rather than a no-op. diff --git a/packages/core/src/cdk/blocks-backend.test.ts b/packages/core/src/cdk/blocks-backend.test.ts index afc7b772..4d971bd3 100644 --- a/packages/core/src/cdk/blocks-backend.test.ts +++ b/packages/core/src/cdk/blocks-backend.test.ts @@ -6,8 +6,10 @@ import assert from 'node:assert'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { BlocksBackend } from './blocks-backend.js'; +import { Scope } from './index.js'; // Simulate the CDK condition being active (tests import CDK files directly) before(() => { @@ -19,6 +21,7 @@ const handlerPath = join(__dirname, '__fixtures__', 'handler.js'); const sideEffectBackendPath = join(__dirname, '__fixtures__', 'side-effect-backend.js'); const factoryBackendPath = join(__dirname, '__fixtures__', 'factory-backend.js'); const fullIdConstructBackendPath = join(__dirname, '__fixtures__', 'fullid-construct-backend.js'); +const EXECUTION_ROLE_MARKER_ACTION = 'blocks-test:MarkerAction'; describe('ESM cache-busting (multi-stage)', () => { test('BlocksBackend.create() with same backendCDKPath but different IDs produces constructs in each', async () => { @@ -89,6 +92,92 @@ describe('synth shape (drop into existing stack)', () => { }); }); +describe('shared execution role', () => { + test('exposes executionRole on the backend', async () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'RoleSurfaceStack'); + + const backend = await BlocksBackend.create(parent, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + assert.ok(backend.executionRole, 'BlocksBackend should expose .executionRole'); + }); + + test('synth produces a Lambda-assumable role with basic execution, and the handler uses it', async () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'RoleSynthStack'); + + await BlocksBackend.create(parent, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + const template = Template.fromStack(parent); + + // The shared role (logical id derived from the 'BlocksRole' construct id) + // is assumable by Lambda and carries AWSLambdaBasicExecutionRole (so + // CloudWatch Logs keep working after swapping off the auto-role). Other + // roles exist (API Gateway CloudWatch role, config BucketDeployment role), + // so we target ours by logical id. + const roles = template.findResources('AWS::IAM::Role'); + const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole')); + assert.ok(blocksRoleId, 'expected a role from the BlocksRole construct'); + const blocksRole = roles[blocksRoleId]; + assert.deepStrictEqual(blocksRole.Properties.AssumeRolePolicyDocument.Statement[0], { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + }); + assert.ok( + JSON.stringify(blocksRole.Properties.ManagedPolicyArns ?? []).includes('AWSLambdaBasicExecutionRole'), + 'BlocksRole should attach AWSLambdaBasicExecutionRole', + ); + + // The Blocks handler references the shared role, not an auto-generated one. + template.hasResourceProperties('AWS::Lambda::Function', { + Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] }, + }); + }); + + test('a nested block resolves executionRole via the construct-tree walk', async () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'RoleResolveStack'); + + const backend = await BlocksBackend.create(parent, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + // Build nested Scopes under the backend (outer → inner), the same shape a + // real Building Block tree has, and grant a uniquely-named marker action to + // `this.executionRole` from the innermost scope. If the getter's tree-walk + // failed, it would resolve the wrong role (or throw), and the marker would + // not land on the backend's shared role. + // `create()` sets globalThis.CURRENT_BLOCKS_STACK = backend, so a parent-less + // Scope attaches under the backend (the same way a real backend module's + // top-level blocks do); `inner` is then nested one level deeper. + const outer = new Scope('outer'); + const inner = new Scope('inner', { parent: outer }); + + // Resolves to the backend's shared role from two levels deep. + assert.strictEqual(inner.executionRole, backend.executionRole); + + inner.executionRole.addToPrincipalPolicy( + new PolicyStatement({ actions: [EXECUTION_ROLE_MARKER_ACTION], resources: ['*'] }), + ); + + // The grant lands on the shared role's default inline policy (AWS::IAM::Policy). + const template = Template.fromStack(parent); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([Match.objectLike({ Action: EXECUTION_ROLE_MARKER_ACTION })]), + }, + }); + }); +}); + describe('factory function support', () => { test('BlocksBackend.create() calls default export function with the backend instance', async () => { const app = new cdk.App(); diff --git a/packages/core/src/cdk/blocks-backend.ts b/packages/core/src/cdk/blocks-backend.ts index 7387d26f..315df242 100644 --- a/packages/core/src/cdk/blocks-backend.ts +++ b/packages/core/src/cdk/blocks-backend.ts @@ -4,6 +4,7 @@ import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { CfnGroup } from 'aws-cdk-lib/aws-resourcegroups'; import { Construct } from 'constructs'; import { pathToFileURL } from 'node:url'; @@ -48,10 +49,29 @@ export interface BlocksBackendProps { /** Shared infra setup — creates Lambda + API Gateway on the given scope. */ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id?: string) { + // ── Shared execution role ────────────────────────────────────────────── + // A single IAM role that every Building Block grants to. Provisioned here so + // it exists before the backend module is imported (Building Blocks reach it + // via `scope.executionRole`). Block grants sit on the role's default (inline) + // policy, exactly as they did on the auto-generated NodejsFunction role. + // + // AWSLambdaBasicExecutionRole is attached explicitly because the auto-role + // included it by default — omitting it would silently break CloudWatch Logs. + const executionRole = new iam.Role(scope, 'BlocksRole', { + // CompositePrincipal (rather than a bare ServicePrincipal) so additional + // compute types can assume this same shared role as they are introduced + // (e.g. ECS tasks via ecs-tasks.amazonaws.com), by adding principals here. + assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), + ], + }); + const handler = new lambda.NodejsFunction(scope, 'Handler', { entry: props.backendHandlerPath, runtime: DEFAULT_NODE_RUNTIME, handler: 'handler', + role: executionRole, memorySize: 2048, timeout: cdk.Duration.seconds(60 * 15), environment: { @@ -152,7 +172,7 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id registerBuiltinRoutes(); - return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}` }; + return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}`, executionRole }; } /** @@ -177,6 +197,8 @@ export class BlocksBackend extends Construct { public readonly gateway: apigateway.RestApi; public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction; public readonly backendHandlerPath: string; + /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ + public readonly executionRole: iam.IRole; /** * The fullId used by child Scopes to compute their env var names, @@ -221,6 +243,7 @@ export class BlocksBackend extends Construct { this.handler = infra.handler; this.gateway = infra.gateway; this.apiUrl = infra.apiUrl; + this.executionRole = infra.executionRole; // Override BLOCKS_STACK_NAME to include the parent stack name so runtime // resource lookups (DynamoDB table names) match the CDK-time fullId diff --git a/packages/core/src/cdk/blocks-stack.test.ts b/packages/core/src/cdk/blocks-stack.test.ts index ed0caf14..2b0cae2e 100644 --- a/packages/core/src/cdk/blocks-stack.test.ts +++ b/packages/core/src/cdk/blocks-stack.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; import { BlocksBackend } from './blocks-backend.js'; -import { BlocksStack } from './index.js'; +import { BlocksStack, Scope } from './index.js'; // Simulate the CDK condition being active (tests import CDK files directly) before(() => { @@ -78,6 +78,46 @@ describe('legacy side-effect mode (no default export)', () => { }); }); +describe('shared execution role (BlocksStack)', () => { + // The role synth shape and the Scope.executionRole tree-walk are shared code + // (setupBlocksInfra + the getter), covered in blocks-backend.test.ts. The only + // BlocksStack-specific behavior is that its own constructor wires + // executionRole — a separate code path from BlocksBackend's constructor. + test('BlocksStack wires executionRole via its constructor', async () => { + const app = new cdk.App(); + const stack = await BlocksStack.create(app, 'StackRoleStack', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + assert.ok(stack.executionRole, 'BlocksStack should expose a populated .executionRole'); + }); +}); + +describe('executionRole globalThis fallback', () => { + test('resolves via globalThis.CURRENT_BLOCKS_STACK when no owner is in the tree', async () => { + const app = new cdk.App(); + const stack = await BlocksStack.create(app, 'FallbackStack', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + // A Scope whose construct-tree ancestry has no BlocksStack/BlocksBackend + // (parented under a plain cdk.Stack) exhausts the tree-walk and falls back + // to globalThis.CURRENT_BLOCKS_STACK. The `as any` is test plumbing — a + // plain Stack isn't a ScopeParent, but it IS a valid Construct parent. + const plainStack = new cdk.Stack(app, 'PlainStack'); + (globalThis as any).CURRENT_BLOCKS_STACK = stack; + const orphan = new Scope('orphan', { parent: plainStack as any }); + + assert.strictEqual( + orphan.executionRole, + stack.executionRole, + 'fallback resolves to the ambient stack role', + ); + }); +}); + describe('assertCdkConditionActive', () => { test('BlocksStack.create() throws when --conditions=cdk is missing', async () => { const origNodeOptions = process.env.NODE_OPTIONS; diff --git a/packages/core/src/cdk/index.ts b/packages/core/src/cdk/index.ts index 2e938eba..3642a944 100644 --- a/packages/core/src/cdk/index.ts +++ b/packages/core/src/cdk/index.ts @@ -30,6 +30,8 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { public readonly gateway: cdk.aws_apigateway.RestApi; public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction; public readonly backendHandlerPath: string; + /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ + public readonly executionRole: cdk.aws_iam.IRole; private constructor(scope: Construct, id: string, props: BlocksStackProps) { super(scope, id, props); @@ -43,6 +45,7 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { this.handler = infra.handler; this.gateway = infra.gateway; this.apiUrl = infra.apiUrl; + this.executionRole = infra.executionRole; } static async create(scope: Construct, id: string, props: BlocksStackProps) { @@ -103,6 +106,28 @@ export class Scope extends Construct { return ((globalThis as any).CURRENT_BLOCKS_STACK as { handler: cdk.aws_lambda_nodejs.NodejsFunction }).handler; } + /** + * The shared IAM role assumed by all Blocks compute. Building Blocks grant + * their permissions to this role instead of to an individual function's + * auto-role. CDK's `grant*()` / `addToPrincipalPolicy()` route those grants + * to the role's default (inline) policy — exactly where they landed on the + * auto-generated role before. + * + * Resolves the same way as {@link handler}: walk up to the owning + * BlocksStack/BlocksBackend, falling back to the ambient stack. + */ + get executionRole(): cdk.aws_iam.IRole { + let current: Construct = this; + while (current.node.scope) { + current = current.node.scope as Construct; + if (current instanceof BlocksStack || current instanceof BlocksBackend) { + return current.executionRole; + } + } + // Fallback to globalThis for backward compatibility + return ((globalThis as any).CURRENT_BLOCKS_STACK as { executionRole: cdk.aws_iam.IRole }).executionRole; + } + get fullId(): string { return computeScopeFullId(this); }