Skip to content
Open
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
18 changes: 18 additions & 0 deletions .changeset/core-shared-execution-role.md
Original file line number Diff line number Diff line change
@@ -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.
91 changes: 90 additions & 1 deletion packages/core/src/cdk/blocks-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -89,6 +92,92 @@ describe('synth shape (drop into existing stack)', () => {
});
});

describe('shared execution role', () => {
test('exposes executionRole on the backend', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test-coverage gaps (non-blocking, but this is the foundation the whole A-series builds on, so worth locking down now):

  1. BlocksStack path is untested. All four new tests exercise BlocksBackend. But index.ts BlocksStack gained the identical executionRole surface + wiring, and it has its own blocks-stack.test.ts. setupBlocksInfra is shared so behavior should match, but "should" is exactly what a test pins — a mirror test in blocks-stack.test.ts (or a note explaining why the shared-infra test covers both) closes it.
  2. The getter's globalThis fallback branch is untested. The nested-resolution test hits the tree-walk branch; the return ((globalThis as any).CURRENT_BLOCKS_STACK...).executionRole fallback — the branch that fires for a parent-less Scope with no owner in the tree — is never exercised. That's the branch most likely to throw (undefined deref) in real misuse, so it's the one most worth a test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both gaps now covered in blocks-stack.test.ts: (1) a BlocksStack mirror test asserting the executionRole surface, the handler assuming BlocksRole, and nested-block resolution landing a grant on the role inline policy; (2) a test for the getter globalThis fallback branch — a Scope parented under a plain cdk.Stack (no Blocks owner in the tree) resolves via globalThis.CURRENT_BLOCKS_STACK.

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();
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/cdk/blocks-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"Non-breaking / synth output unchanged" is true at the runtime level but not at the CloudFormation level, and the gap matters for anyone upgrading a live stack. On base, the handler used NodejsFunction's auto-generated role (logical id ...HandlerServiceRole...). I confirmed base has no explicit role: on the function. Introducing new iam.Role(scope, 'BlocksRole', ...) and passing role: executionRole means the auto-role logical id disappears and a new BlocksRole* logical id appears.

Consequence: on an existing deployed stack, cdk deploy replaces the execution role — the old role is deleted and a new one created. Harmless for a stateless Lambda exec role (no data, grants re-attach identically), and there's no downtime concern here, but it is a resource replacement, not a no-op diff. The claim "synth output ... unchanged" will surprise an operator who diffs the change set and sees a role delete+create. Worth a one-line migration note in the changeset: existing stacks replace the Lambda execution role on upgrade; runtime-equivalent, no action needed. (Also worth confirming nothing external references the old role ARN — e.g. a resource policy or trust relationship outside this stack.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — you are right that this is a CloudFormation-level role replacement, not a true no-op. Added a migration note to the changeset: upgrading an existing stack deletes the auto-generated role and creates BlocksRole; runtime-equivalent (grants re-attach identically) and no action needed, but a change-set diff shows a role delete+create. On the external-reference point: nothing in this repo references the old auto-role ARN (it was an unnamed, auto-generated logical id, never exported or surfaced), so there is no trust-relationship or resource-policy dependency to break.

// 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')),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')) wrapping a single principal is functionally identical to just new iam.ServicePrincipal('lambda.amazonaws.com') — the CompositePrincipal adds nothing until there's a second principal. I assume this is deliberate scaffolding for later multi-compute (ECS/other services assuming the same role), which would be a good reason to keep it — but as written it reads as accidental over-construction. Either add a short comment (// CompositePrincipal so later compute types can assume the same role — A-series) so the intent is legible, or drop the wrapper until the second principal actually arrives. Given the PR is explicitly the 'foundational' step, I'd lean toward the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional scaffolding — went with your lean and added a comment. It is there so additional compute types can assume the same shared role by adding principals (e.g. ecs-tasks.amazonaws.com) rather than restructuring the role later. Comment now makes that legible at the call site.

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: {
Expand Down Expand Up @@ -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 };
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion packages/core/src/cdk/blocks-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/cdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplication: this getter's tree-walk is now a verbatim copy of get handler() right above it — same while (current.node.scope) loop, same instanceof BlocksStack || BlocksBackend check, same globalThis fallback, even the same (repo-atypical) 4-space body indentation carried over. Two copies of a construct-tree walk will drift: the day someone adds a third owner type or fixes the fallback, they'll update one and miss the other.

Extract the shared traversal, e.g. private owner(): BlocksStack | BlocksBackend | undefined (or a walkToOwner() returning the matched construct), and have both getters read this.owner()?.handler / ?.executionRole with the shared fallback. Small refactor, removes the drift risk, and makes the "resolves the same way as handler" comment true by construction instead of by copy-paste.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deliberately leaving these as two copies. get handler() is on its way out — once the event/observability blocks migrate off this.handler to this.compute/this.executionRole, the handler getter is removed entirely. Extracting a shared owner() helper now would create an abstraction whose two callers collapse to one shortly after, so the drift risk you describe has a short lifetime and the refactor would just be undone. Keeping executionRole as a standalone copy means its removal-partner (handler) can be deleted without untangling a shared helper. Will revisit if handler outlives expectations.

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);
}
Expand Down
Loading