@aws-blocks/core 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import * as cdk from 'aws-cdk-lib';
2
2
  import * as apigateway from 'aws-cdk-lib/aws-apigateway';
3
+ import * as iam from 'aws-cdk-lib/aws-iam';
3
4
  import { Construct } from 'constructs';
4
5
  /**
5
6
  * Validate that the Node.js process was started with `--conditions=cdk`.
@@ -19,6 +20,7 @@ export declare function setupBlocksInfra(scope: Construct, props: BlocksBackendP
19
20
  handler: cdk.aws_lambda_nodejs.NodejsFunction;
20
21
  gateway: cdk.aws_apigateway.RestApi;
21
22
  apiUrl: string;
23
+ executionRole: cdk.aws_iam.Role;
22
24
  };
23
25
  /**
24
26
  * Standalone CDK construct that provisions the Blocks backend: a single Lambda
@@ -42,6 +44,8 @@ export declare class BlocksBackend extends Construct {
42
44
  readonly gateway: apigateway.RestApi;
43
45
  readonly handler: cdk.aws_lambda_nodejs.NodejsFunction;
44
46
  readonly backendHandlerPath: string;
47
+ /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
48
+ readonly executionRole: iam.IRole;
45
49
  /**
46
50
  * The fullId used by child Scopes to compute their env var names,
47
51
  * construct IDs, and physical resource names (e.g., DynamoDB table names).
@@ -1 +1 @@
1
- {"version":3,"file":"blocks-backend.d.ts","sourceRoot":"","sources":["../../src/cdk/blocks-backend.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AAEnC,OAAO,KAAK,UAAU,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAQvC;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAkB/C;AAED,MAAM,WAAW,kBAAkB;IACjC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,CAAC,EAAE,MAAM;;;;EA0GxF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,aAAc,SAAQ,SAAS;IAC1C,SAAgB,MAAM,EAAE,MAAM,CAAC;IAC/B,SAAgB,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC;IAC5C,SAAgB,OAAO,EAAE,GAAG,CAAC,iBAAiB,CAAC,cAAc,CAAC;IAC9D,SAAgB,kBAAkB,EAAE,MAAM,CAAC;IAE3C;;;;;;;;;;;;;;;;;;OAkBG;IACH,IAAI,MAAM,IAAI,MAAM,CAUnB;IAED,OAAO;WAmBM,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB;CAsB5E"}
1
+ {"version":3,"file":"blocks-backend.d.ts","sourceRoot":"","sources":["../../src/cdk/blocks-backend.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AAEnC,OAAO,KAAK,UAAU,MAAM,4BAA4B,CAAC;AACzD,OAAO,KAAK,GAAG,MAAM,qBAAqB,CAAC;AAE3C,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAQvC;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAkB/C;AAED,MAAM,WAAW,kBAAkB;IACjC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,CAAC,EAAE,MAAM;;;;;EA6HxF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,aAAc,SAAQ,SAAS;IAC1C,SAAgB,MAAM,EAAE,MAAM,CAAC;IAC/B,SAAgB,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC;IAC5C,SAAgB,OAAO,EAAE,GAAG,CAAC,iBAAiB,CAAC,cAAc,CAAC;IAC9D,SAAgB,kBAAkB,EAAE,MAAM,CAAC;IAC3C,yFAAyF;IACzF,SAAgB,aAAa,EAAE,GAAG,CAAC,KAAK,CAAC;IAEzC;;;;;;;;;;;;;;;;;;OAkBG;IACH,IAAI,MAAM,IAAI,MAAM,CAUnB;IAED,OAAO;WAoBM,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB;CAsB5E"}
@@ -3,6 +3,7 @@
3
3
  import * as cdk from 'aws-cdk-lib';
4
4
  import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
5
5
  import * as apigateway from 'aws-cdk-lib/aws-apigateway';
6
+ import * as iam from 'aws-cdk-lib/aws-iam';
6
7
  import { CfnGroup } from 'aws-cdk-lib/aws-resourcegroups';
7
8
  import { Construct } from 'constructs';
8
9
  import { pathToFileURL } from 'node:url';
@@ -35,10 +36,28 @@ export function assertCdkConditionActive() {
35
36
  }
36
37
  /** Shared infra setup — creates Lambda + API Gateway on the given scope. */
37
38
  export function setupBlocksInfra(scope, props, id) {
39
+ // ── Shared execution role ──────────────────────────────────────────────
40
+ // A single IAM role that every Building Block grants to. Provisioned here so
41
+ // it exists before the backend module is imported (Building Blocks reach it
42
+ // via `scope.executionRole`). Block grants sit on the role's default (inline)
43
+ // policy, exactly as they did on the auto-generated NodejsFunction role.
44
+ //
45
+ // AWSLambdaBasicExecutionRole is attached explicitly because the auto-role
46
+ // included it by default — omitting it would silently break CloudWatch Logs.
47
+ const executionRole = new iam.Role(scope, 'BlocksRole', {
48
+ // CompositePrincipal (rather than a bare ServicePrincipal) so additional
49
+ // compute types can assume this same shared role as they are introduced
50
+ // (e.g. ECS tasks via ecs-tasks.amazonaws.com), by adding principals here.
51
+ assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')),
52
+ managedPolicies: [
53
+ iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
54
+ ],
55
+ });
38
56
  const handler = new lambda.NodejsFunction(scope, 'Handler', {
39
57
  entry: props.backendHandlerPath,
40
58
  runtime: DEFAULT_NODE_RUNTIME,
41
59
  handler: 'handler',
60
+ role: executionRole,
42
61
  memorySize: 2048,
43
62
  timeout: cdk.Duration.seconds(60 * 15),
44
63
  environment: {
@@ -126,7 +145,7 @@ export function setupBlocksInfra(scope, props, id) {
126
145
  registerConfig(scope, 'BB_RESOURCES_GROUP_URL', resourcesUrl);
127
146
  registerConfig(scope, 'BB_SETTINGS_GROUP_URL', settingsUrl);
128
147
  registerBuiltinRoutes();
129
- return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}` };
148
+ return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}`, executionRole };
130
149
  }
131
150
  /**
132
151
  * Standalone CDK construct that provisions the Blocks backend: a single Lambda
@@ -150,6 +169,8 @@ export class BlocksBackend extends Construct {
150
169
  gateway;
151
170
  handler;
152
171
  backendHandlerPath;
172
+ /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
173
+ executionRole;
153
174
  /**
154
175
  * The fullId used by child Scopes to compute their env var names,
155
176
  * construct IDs, and physical resource names (e.g., DynamoDB table names).
@@ -189,6 +210,7 @@ export class BlocksBackend extends Construct {
189
210
  this.handler = infra.handler;
190
211
  this.gateway = infra.gateway;
191
212
  this.apiUrl = infra.apiUrl;
213
+ this.executionRole = infra.executionRole;
192
214
  // Override BLOCKS_STACK_NAME to include the parent stack name so runtime
193
215
  // resource lookups (DynamoDB table names) match the CDK-time fullId
194
216
  // and are unique per deployment.
@@ -5,8 +5,10 @@ import assert from 'node:assert';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { dirname, join } from 'node:path';
7
7
  import * as cdk from 'aws-cdk-lib';
8
- import { Template } from 'aws-cdk-lib/assertions';
8
+ import { Template, Match } from 'aws-cdk-lib/assertions';
9
+ import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
9
10
  import { BlocksBackend } from './blocks-backend.js';
11
+ import { Scope } from './index.js';
10
12
  // Simulate the CDK condition being active (tests import CDK files directly)
11
13
  before(() => {
12
14
  process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS ?? '') + ' --conditions=cdk';
@@ -16,6 +18,7 @@ const handlerPath = join(__dirname, '__fixtures__', 'handler.js');
16
18
  const sideEffectBackendPath = join(__dirname, '__fixtures__', 'side-effect-backend.js');
17
19
  const factoryBackendPath = join(__dirname, '__fixtures__', 'factory-backend.js');
18
20
  const fullIdConstructBackendPath = join(__dirname, '__fixtures__', 'fullid-construct-backend.js');
21
+ const EXECUTION_ROLE_MARKER_ACTION = 'blocks-test:MarkerAction';
19
22
  describe('ESM cache-busting (multi-stage)', () => {
20
23
  test('BlocksBackend.create() with same backendCDKPath but different IDs produces constructs in each', async () => {
21
24
  const app = new cdk.App();
@@ -66,6 +69,73 @@ describe('synth shape (drop into existing stack)', () => {
66
69
  template.resourceCountIs('AWS::ApiGateway::RestApi', 2);
67
70
  });
68
71
  });
72
+ describe('shared execution role', () => {
73
+ test('exposes executionRole on the backend', async () => {
74
+ const app = new cdk.App();
75
+ const parent = new cdk.Stack(app, 'RoleSurfaceStack');
76
+ const backend = await BlocksBackend.create(parent, 'Blocks', {
77
+ backendHandlerPath: handlerPath,
78
+ backendCDKPath: sideEffectBackendPath,
79
+ });
80
+ assert.ok(backend.executionRole, 'BlocksBackend should expose .executionRole');
81
+ });
82
+ test('synth produces a Lambda-assumable role with basic execution, and the handler uses it', async () => {
83
+ const app = new cdk.App();
84
+ const parent = new cdk.Stack(app, 'RoleSynthStack');
85
+ await BlocksBackend.create(parent, 'Blocks', {
86
+ backendHandlerPath: handlerPath,
87
+ backendCDKPath: sideEffectBackendPath,
88
+ });
89
+ const template = Template.fromStack(parent);
90
+ // The shared role (logical id derived from the 'BlocksRole' construct id)
91
+ // is assumable by Lambda and carries AWSLambdaBasicExecutionRole (so
92
+ // CloudWatch Logs keep working after swapping off the auto-role). Other
93
+ // roles exist (API Gateway CloudWatch role, config BucketDeployment role),
94
+ // so we target ours by logical id.
95
+ const roles = template.findResources('AWS::IAM::Role');
96
+ const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole'));
97
+ assert.ok(blocksRoleId, 'expected a role from the BlocksRole construct');
98
+ const blocksRole = roles[blocksRoleId];
99
+ assert.deepStrictEqual(blocksRole.Properties.AssumeRolePolicyDocument.Statement[0], {
100
+ Action: 'sts:AssumeRole',
101
+ Effect: 'Allow',
102
+ Principal: { Service: 'lambda.amazonaws.com' },
103
+ });
104
+ assert.ok(JSON.stringify(blocksRole.Properties.ManagedPolicyArns ?? []).includes('AWSLambdaBasicExecutionRole'), 'BlocksRole should attach AWSLambdaBasicExecutionRole');
105
+ // The Blocks handler references the shared role, not an auto-generated one.
106
+ template.hasResourceProperties('AWS::Lambda::Function', {
107
+ Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] },
108
+ });
109
+ });
110
+ test('a nested block resolves executionRole via the construct-tree walk', async () => {
111
+ const app = new cdk.App();
112
+ const parent = new cdk.Stack(app, 'RoleResolveStack');
113
+ const backend = await BlocksBackend.create(parent, 'Blocks', {
114
+ backendHandlerPath: handlerPath,
115
+ backendCDKPath: sideEffectBackendPath,
116
+ });
117
+ // Build nested Scopes under the backend (outer → inner), the same shape a
118
+ // real Building Block tree has, and grant a uniquely-named marker action to
119
+ // `this.executionRole` from the innermost scope. If the getter's tree-walk
120
+ // failed, it would resolve the wrong role (or throw), and the marker would
121
+ // not land on the backend's shared role.
122
+ // `create()` sets globalThis.CURRENT_BLOCKS_STACK = backend, so a parent-less
123
+ // Scope attaches under the backend (the same way a real backend module's
124
+ // top-level blocks do); `inner` is then nested one level deeper.
125
+ const outer = new Scope('outer');
126
+ const inner = new Scope('inner', { parent: outer });
127
+ // Resolves to the backend's shared role from two levels deep.
128
+ assert.strictEqual(inner.executionRole, backend.executionRole);
129
+ inner.executionRole.addToPrincipalPolicy(new PolicyStatement({ actions: [EXECUTION_ROLE_MARKER_ACTION], resources: ['*'] }));
130
+ // The grant lands on the shared role's default inline policy (AWS::IAM::Policy).
131
+ const template = Template.fromStack(parent);
132
+ template.hasResourceProperties('AWS::IAM::Policy', {
133
+ PolicyDocument: {
134
+ Statement: Match.arrayWith([Match.objectLike({ Action: EXECUTION_ROLE_MARKER_ACTION })]),
135
+ },
136
+ });
137
+ });
138
+ });
69
139
  describe('factory function support', () => {
70
140
  test('BlocksBackend.create() calls default export function with the backend instance', async () => {
71
141
  const app = new cdk.App();
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { dirname, join } from 'node:path';
7
7
  import * as cdk from 'aws-cdk-lib';
8
8
  import { BlocksBackend } from './blocks-backend.js';
9
- import { BlocksStack } from './index.js';
9
+ import { BlocksStack, Scope } from './index.js';
10
10
  // Simulate the CDK condition being active (tests import CDK files directly)
11
11
  before(() => {
12
12
  process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS ?? '') + ' --conditions=cdk';
@@ -54,6 +54,37 @@ describe('legacy side-effect mode (no default export)', () => {
54
54
  assert.ok(marker, 'Side-effect-only module should register construct via globalThis.CURRENT_BLOCKS_STACK');
55
55
  });
56
56
  });
57
+ describe('shared execution role (BlocksStack)', () => {
58
+ // The role synth shape and the Scope.executionRole tree-walk are shared code
59
+ // (setupBlocksInfra + the getter), covered in blocks-backend.test.ts. The only
60
+ // BlocksStack-specific behavior is that its own constructor wires
61
+ // executionRole — a separate code path from BlocksBackend's constructor.
62
+ test('BlocksStack wires executionRole via its constructor', async () => {
63
+ const app = new cdk.App();
64
+ const stack = await BlocksStack.create(app, 'StackRoleStack', {
65
+ backendHandlerPath: handlerPath,
66
+ backendCDKPath: sideEffectBackendPath,
67
+ });
68
+ assert.ok(stack.executionRole, 'BlocksStack should expose a populated .executionRole');
69
+ });
70
+ });
71
+ describe('executionRole globalThis fallback', () => {
72
+ test('resolves via globalThis.CURRENT_BLOCKS_STACK when no owner is in the tree', async () => {
73
+ const app = new cdk.App();
74
+ const stack = await BlocksStack.create(app, 'FallbackStack', {
75
+ backendHandlerPath: handlerPath,
76
+ backendCDKPath: sideEffectBackendPath,
77
+ });
78
+ // A Scope whose construct-tree ancestry has no BlocksStack/BlocksBackend
79
+ // (parented under a plain cdk.Stack) exhausts the tree-walk and falls back
80
+ // to globalThis.CURRENT_BLOCKS_STACK. The `as any` is test plumbing — a
81
+ // plain Stack isn't a ScopeParent, but it IS a valid Construct parent.
82
+ const plainStack = new cdk.Stack(app, 'PlainStack');
83
+ globalThis.CURRENT_BLOCKS_STACK = stack;
84
+ const orphan = new Scope('orphan', { parent: plainStack });
85
+ assert.strictEqual(orphan.executionRole, stack.executionRole, 'fallback resolves to the ambient stack role');
86
+ });
87
+ });
57
88
  describe('assertCdkConditionActive', () => {
58
89
  test('BlocksStack.create() throws when --conditions=cdk is missing', async () => {
59
90
  const origNodeOptions = process.env.NODE_OPTIONS;
@@ -14,6 +14,8 @@ export declare class BlocksStack extends cdk.Stack implements BaseBlocksStack {
14
14
  readonly gateway: cdk.aws_apigateway.RestApi;
15
15
  readonly handler: cdk.aws_lambda_nodejs.NodejsFunction;
16
16
  readonly backendHandlerPath: string;
17
+ /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
18
+ readonly executionRole: cdk.aws_iam.IRole;
17
19
  private constructor();
18
20
  static create(scope: Construct, id: string, props: BlocksStackProps): Promise<BlocksStack>;
19
21
  }
@@ -24,6 +26,17 @@ export declare class Scope extends Construct {
24
26
  readonly bbVersion?: string;
25
27
  constructor(id: string, options?: ScopeOptions);
26
28
  get handler(): cdk.aws_lambda_nodejs.NodejsFunction;
29
+ /**
30
+ * The shared IAM role assumed by all Blocks compute. Building Blocks grant
31
+ * their permissions to this role instead of to an individual function's
32
+ * auto-role. CDK's `grant*()` / `addToPrincipalPolicy()` route those grants
33
+ * to the role's default (inline) policy — exactly where they landed on the
34
+ * auto-generated role before.
35
+ *
36
+ * Resolves the same way as {@link handler}: walk up to the owning
37
+ * BlocksStack/BlocksBackend, falling back to the ambient stack.
38
+ */
39
+ get executionRole(): cdk.aws_iam.IRole;
27
40
  get fullId(): string;
28
41
  protected buildUserAgentChain(): [string, string][];
29
42
  registerClientMiddleware(_packageSpecifier: string): void;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cdk/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAGvC,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,WAAW,IAAI,eAAe,EACnC,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,gCAAgC,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAE7F,qBAAa,WAAY,SAAQ,GAAG,CAAC,KAAM,YAAW,eAAe;IACnE,SAAgB,EAAE,EAAE,MAAM,CAAC;IAC3B,SAAgB,MAAM,EAAE,MAAM,CAAC;IAC/B,SAAgB,OAAO,EAAE,GAAG,CAAC,cAAc,CAAC,OAAO,CAAC;IACpD,SAAgB,OAAO,EAAE,GAAG,CAAC,iBAAiB,CAAC,cAAc,CAAC;IAC9D,SAAgB,kBAAkB,EAAE,MAAM,CAAC;IAE3C,OAAO;WAcM,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB;CA6B1E;AAED,qBAAa,KAAM,SAAQ,SAAS;IAClC,SAAgB,EAAE,EAAE,MAAM,CAAC;IAC3B,SAAgB,MAAM,EAAE,WAAW,CAAC;IAEpC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;gBAEhB,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;IAO9C,IAAI,OAAO,yCAWV;IAED,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,SAAS,CAAC,mBAAmB,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;IAKnD,wBAAwB,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI;IACzD,qBAAqB,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI;IACtD,0BAA0B,CAAC,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IACrH,IAAI,gBAAgB,IAAI,SAAS,MAAM,EAAE,CAAe;IACxD,IAAI,cAAc,IAAI,SAAS,MAAM,EAAE,CAAe;CACvD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cdk/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAGvC,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,WAAW,IAAI,eAAe,EACnC,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,gCAAgC,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAE7F,qBAAa,WAAY,SAAQ,GAAG,CAAC,KAAM,YAAW,eAAe;IACnE,SAAgB,EAAE,EAAE,MAAM,CAAC;IAC3B,SAAgB,MAAM,EAAE,MAAM,CAAC;IAC/B,SAAgB,OAAO,EAAE,GAAG,CAAC,cAAc,CAAC,OAAO,CAAC;IACpD,SAAgB,OAAO,EAAE,GAAG,CAAC,iBAAiB,CAAC,cAAc,CAAC;IAC9D,SAAgB,kBAAkB,EAAE,MAAM,CAAC;IAC3C,yFAAyF;IACzF,SAAgB,aAAa,EAAE,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC;IAEjD,OAAO;WAeM,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB;CA6B1E;AAED,qBAAa,KAAM,SAAQ,SAAS;IAClC,SAAgB,EAAE,EAAE,MAAM,CAAC;IAC3B,SAAgB,MAAM,EAAE,WAAW,CAAC;IAEpC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;gBAEhB,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;IAO9C,IAAI,OAAO,yCAWV;IAED;;;;;;;;;OASG;IACH,IAAI,aAAa,IAAI,GAAG,CAAC,OAAO,CAAC,KAAK,CAUrC;IAED,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,SAAS,CAAC,mBAAmB,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;IAKnD,wBAAwB,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI;IACzD,qBAAqB,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI;IACtD,0BAA0B,CAAC,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IACrH,IAAI,gBAAgB,IAAI,SAAS,MAAM,EAAE,CAAe;IACxD,IAAI,cAAc,IAAI,SAAS,MAAM,EAAE,CAAe;CACvD"}
package/dist/cdk/index.js CHANGED
@@ -20,6 +20,8 @@ export class BlocksStack extends cdk.Stack {
20
20
  gateway;
21
21
  handler;
22
22
  backendHandlerPath;
23
+ /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
24
+ executionRole;
23
25
  constructor(scope, id, props) {
24
26
  super(scope, id, props);
25
27
  this.id = id;
@@ -30,6 +32,7 @@ export class BlocksStack extends cdk.Stack {
30
32
  this.handler = infra.handler;
31
33
  this.gateway = infra.gateway;
32
34
  this.apiUrl = infra.apiUrl;
35
+ this.executionRole = infra.executionRole;
33
36
  }
34
37
  static async create(scope, id, props) {
35
38
  assertCdkConditionActive();
@@ -80,6 +83,27 @@ export class Scope extends Construct {
80
83
  // Fallback to globalThis for backward compatibility
81
84
  return globalThis.CURRENT_BLOCKS_STACK.handler;
82
85
  }
86
+ /**
87
+ * The shared IAM role assumed by all Blocks compute. Building Blocks grant
88
+ * their permissions to this role instead of to an individual function's
89
+ * auto-role. CDK's `grant*()` / `addToPrincipalPolicy()` route those grants
90
+ * to the role's default (inline) policy — exactly where they landed on the
91
+ * auto-generated role before.
92
+ *
93
+ * Resolves the same way as {@link handler}: walk up to the owning
94
+ * BlocksStack/BlocksBackend, falling back to the ambient stack.
95
+ */
96
+ get executionRole() {
97
+ let current = this;
98
+ while (current.node.scope) {
99
+ current = current.node.scope;
100
+ if (current instanceof BlocksStack || current instanceof BlocksBackend) {
101
+ return current.executionRole;
102
+ }
103
+ }
104
+ // Fallback to globalThis for backward compatibility
105
+ return globalThis.CURRENT_BLOCKS_STACK.executionRole;
106
+ }
83
107
  get fullId() {
84
108
  return computeScopeFullId(this);
85
109
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CORE_VERSION = "0.1.17";
1
+ export declare const CORE_VERSION = "0.1.18";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.17';
2
+ export const CORE_VERSION = '0.1.18';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
@@ -6,8 +6,10 @@ import assert from 'node:assert';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { dirname, join } from 'node:path';
8
8
  import * as cdk from 'aws-cdk-lib';
9
- import { Template } from 'aws-cdk-lib/assertions';
9
+ import { Template, Match } from 'aws-cdk-lib/assertions';
10
+ import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
10
11
  import { BlocksBackend } from './blocks-backend.js';
12
+ import { Scope } from './index.js';
11
13
 
12
14
  // Simulate the CDK condition being active (tests import CDK files directly)
13
15
  before(() => {
@@ -19,6 +21,7 @@ const handlerPath = join(__dirname, '__fixtures__', 'handler.js');
19
21
  const sideEffectBackendPath = join(__dirname, '__fixtures__', 'side-effect-backend.js');
20
22
  const factoryBackendPath = join(__dirname, '__fixtures__', 'factory-backend.js');
21
23
  const fullIdConstructBackendPath = join(__dirname, '__fixtures__', 'fullid-construct-backend.js');
24
+ const EXECUTION_ROLE_MARKER_ACTION = 'blocks-test:MarkerAction';
22
25
 
23
26
  describe('ESM cache-busting (multi-stage)', () => {
24
27
  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)', () => {
89
92
  });
90
93
  });
91
94
 
95
+ describe('shared execution role', () => {
96
+ test('exposes executionRole on the backend', async () => {
97
+ const app = new cdk.App();
98
+ const parent = new cdk.Stack(app, 'RoleSurfaceStack');
99
+
100
+ const backend = await BlocksBackend.create(parent, 'Blocks', {
101
+ backendHandlerPath: handlerPath,
102
+ backendCDKPath: sideEffectBackendPath,
103
+ });
104
+
105
+ assert.ok(backend.executionRole, 'BlocksBackend should expose .executionRole');
106
+ });
107
+
108
+ test('synth produces a Lambda-assumable role with basic execution, and the handler uses it', async () => {
109
+ const app = new cdk.App();
110
+ const parent = new cdk.Stack(app, 'RoleSynthStack');
111
+
112
+ await BlocksBackend.create(parent, 'Blocks', {
113
+ backendHandlerPath: handlerPath,
114
+ backendCDKPath: sideEffectBackendPath,
115
+ });
116
+
117
+ const template = Template.fromStack(parent);
118
+
119
+ // The shared role (logical id derived from the 'BlocksRole' construct id)
120
+ // is assumable by Lambda and carries AWSLambdaBasicExecutionRole (so
121
+ // CloudWatch Logs keep working after swapping off the auto-role). Other
122
+ // roles exist (API Gateway CloudWatch role, config BucketDeployment role),
123
+ // so we target ours by logical id.
124
+ const roles = template.findResources('AWS::IAM::Role');
125
+ const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole'));
126
+ assert.ok(blocksRoleId, 'expected a role from the BlocksRole construct');
127
+ const blocksRole = roles[blocksRoleId];
128
+ assert.deepStrictEqual(blocksRole.Properties.AssumeRolePolicyDocument.Statement[0], {
129
+ Action: 'sts:AssumeRole',
130
+ Effect: 'Allow',
131
+ Principal: { Service: 'lambda.amazonaws.com' },
132
+ });
133
+ assert.ok(
134
+ JSON.stringify(blocksRole.Properties.ManagedPolicyArns ?? []).includes('AWSLambdaBasicExecutionRole'),
135
+ 'BlocksRole should attach AWSLambdaBasicExecutionRole',
136
+ );
137
+
138
+ // The Blocks handler references the shared role, not an auto-generated one.
139
+ template.hasResourceProperties('AWS::Lambda::Function', {
140
+ Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] },
141
+ });
142
+ });
143
+
144
+ test('a nested block resolves executionRole via the construct-tree walk', async () => {
145
+ const app = new cdk.App();
146
+ const parent = new cdk.Stack(app, 'RoleResolveStack');
147
+
148
+ const backend = await BlocksBackend.create(parent, 'Blocks', {
149
+ backendHandlerPath: handlerPath,
150
+ backendCDKPath: sideEffectBackendPath,
151
+ });
152
+
153
+ // Build nested Scopes under the backend (outer → inner), the same shape a
154
+ // real Building Block tree has, and grant a uniquely-named marker action to
155
+ // `this.executionRole` from the innermost scope. If the getter's tree-walk
156
+ // failed, it would resolve the wrong role (or throw), and the marker would
157
+ // not land on the backend's shared role.
158
+ // `create()` sets globalThis.CURRENT_BLOCKS_STACK = backend, so a parent-less
159
+ // Scope attaches under the backend (the same way a real backend module's
160
+ // top-level blocks do); `inner` is then nested one level deeper.
161
+ const outer = new Scope('outer');
162
+ const inner = new Scope('inner', { parent: outer });
163
+
164
+ // Resolves to the backend's shared role from two levels deep.
165
+ assert.strictEqual(inner.executionRole, backend.executionRole);
166
+
167
+ inner.executionRole.addToPrincipalPolicy(
168
+ new PolicyStatement({ actions: [EXECUTION_ROLE_MARKER_ACTION], resources: ['*'] }),
169
+ );
170
+
171
+ // The grant lands on the shared role's default inline policy (AWS::IAM::Policy).
172
+ const template = Template.fromStack(parent);
173
+ template.hasResourceProperties('AWS::IAM::Policy', {
174
+ PolicyDocument: {
175
+ Statement: Match.arrayWith([Match.objectLike({ Action: EXECUTION_ROLE_MARKER_ACTION })]),
176
+ },
177
+ });
178
+ });
179
+ });
180
+
92
181
  describe('factory function support', () => {
93
182
  test('BlocksBackend.create() calls default export function with the backend instance', async () => {
94
183
  const app = new cdk.App();
@@ -4,6 +4,7 @@
4
4
  import * as cdk from 'aws-cdk-lib';
5
5
  import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
6
6
  import * as apigateway from 'aws-cdk-lib/aws-apigateway';
7
+ import * as iam from 'aws-cdk-lib/aws-iam';
7
8
  import { CfnGroup } from 'aws-cdk-lib/aws-resourcegroups';
8
9
  import { Construct } from 'constructs';
9
10
  import { pathToFileURL } from 'node:url';
@@ -48,10 +49,29 @@ export interface BlocksBackendProps {
48
49
 
49
50
  /** Shared infra setup — creates Lambda + API Gateway on the given scope. */
50
51
  export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id?: string) {
52
+ // ── Shared execution role ──────────────────────────────────────────────
53
+ // A single IAM role that every Building Block grants to. Provisioned here so
54
+ // it exists before the backend module is imported (Building Blocks reach it
55
+ // via `scope.executionRole`). Block grants sit on the role's default (inline)
56
+ // policy, exactly as they did on the auto-generated NodejsFunction role.
57
+ //
58
+ // AWSLambdaBasicExecutionRole is attached explicitly because the auto-role
59
+ // included it by default — omitting it would silently break CloudWatch Logs.
60
+ const executionRole = new iam.Role(scope, 'BlocksRole', {
61
+ // CompositePrincipal (rather than a bare ServicePrincipal) so additional
62
+ // compute types can assume this same shared role as they are introduced
63
+ // (e.g. ECS tasks via ecs-tasks.amazonaws.com), by adding principals here.
64
+ assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')),
65
+ managedPolicies: [
66
+ iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
67
+ ],
68
+ });
69
+
51
70
  const handler = new lambda.NodejsFunction(scope, 'Handler', {
52
71
  entry: props.backendHandlerPath,
53
72
  runtime: DEFAULT_NODE_RUNTIME,
54
73
  handler: 'handler',
74
+ role: executionRole,
55
75
  memorySize: 2048,
56
76
  timeout: cdk.Duration.seconds(60 * 15),
57
77
  environment: {
@@ -152,7 +172,7 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id
152
172
 
153
173
  registerBuiltinRoutes();
154
174
 
155
- return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}` };
175
+ return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}`, executionRole };
156
176
  }
157
177
 
158
178
  /**
@@ -177,6 +197,8 @@ export class BlocksBackend extends Construct {
177
197
  public readonly gateway: apigateway.RestApi;
178
198
  public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction;
179
199
  public readonly backendHandlerPath: string;
200
+ /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
201
+ public readonly executionRole: iam.IRole;
180
202
 
181
203
  /**
182
204
  * The fullId used by child Scopes to compute their env var names,
@@ -221,6 +243,7 @@ export class BlocksBackend extends Construct {
221
243
  this.handler = infra.handler;
222
244
  this.gateway = infra.gateway;
223
245
  this.apiUrl = infra.apiUrl;
246
+ this.executionRole = infra.executionRole;
224
247
 
225
248
  // Override BLOCKS_STACK_NAME to include the parent stack name so runtime
226
249
  // resource lookups (DynamoDB table names) match the CDK-time fullId
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { dirname, join } from 'node:path';
8
8
  import * as cdk from 'aws-cdk-lib';
9
9
  import { BlocksBackend } from './blocks-backend.js';
10
- import { BlocksStack } from './index.js';
10
+ import { BlocksStack, Scope } from './index.js';
11
11
 
12
12
  // Simulate the CDK condition being active (tests import CDK files directly)
13
13
  before(() => {
@@ -78,6 +78,46 @@ describe('legacy side-effect mode (no default export)', () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe('shared execution role (BlocksStack)', () => {
82
+ // The role synth shape and the Scope.executionRole tree-walk are shared code
83
+ // (setupBlocksInfra + the getter), covered in blocks-backend.test.ts. The only
84
+ // BlocksStack-specific behavior is that its own constructor wires
85
+ // executionRole — a separate code path from BlocksBackend's constructor.
86
+ test('BlocksStack wires executionRole via its constructor', async () => {
87
+ const app = new cdk.App();
88
+ const stack = await BlocksStack.create(app, 'StackRoleStack', {
89
+ backendHandlerPath: handlerPath,
90
+ backendCDKPath: sideEffectBackendPath,
91
+ });
92
+
93
+ assert.ok(stack.executionRole, 'BlocksStack should expose a populated .executionRole');
94
+ });
95
+ });
96
+
97
+ describe('executionRole globalThis fallback', () => {
98
+ test('resolves via globalThis.CURRENT_BLOCKS_STACK when no owner is in the tree', async () => {
99
+ const app = new cdk.App();
100
+ const stack = await BlocksStack.create(app, 'FallbackStack', {
101
+ backendHandlerPath: handlerPath,
102
+ backendCDKPath: sideEffectBackendPath,
103
+ });
104
+
105
+ // A Scope whose construct-tree ancestry has no BlocksStack/BlocksBackend
106
+ // (parented under a plain cdk.Stack) exhausts the tree-walk and falls back
107
+ // to globalThis.CURRENT_BLOCKS_STACK. The `as any` is test plumbing — a
108
+ // plain Stack isn't a ScopeParent, but it IS a valid Construct parent.
109
+ const plainStack = new cdk.Stack(app, 'PlainStack');
110
+ (globalThis as any).CURRENT_BLOCKS_STACK = stack;
111
+ const orphan = new Scope('orphan', { parent: plainStack as any });
112
+
113
+ assert.strictEqual(
114
+ orphan.executionRole,
115
+ stack.executionRole,
116
+ 'fallback resolves to the ambient stack role',
117
+ );
118
+ });
119
+ });
120
+
81
121
  describe('assertCdkConditionActive', () => {
82
122
  test('BlocksStack.create() throws when --conditions=cdk is missing', async () => {
83
123
  const origNodeOptions = process.env.NODE_OPTIONS;
package/src/cdk/index.ts CHANGED
@@ -30,6 +30,8 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
30
30
  public readonly gateway: cdk.aws_apigateway.RestApi;
31
31
  public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction;
32
32
  public readonly backendHandlerPath: string;
33
+ /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
34
+ public readonly executionRole: cdk.aws_iam.IRole;
33
35
 
34
36
  private constructor(scope: Construct, id: string, props: BlocksStackProps) {
35
37
  super(scope, id, props);
@@ -43,6 +45,7 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
43
45
  this.handler = infra.handler;
44
46
  this.gateway = infra.gateway;
45
47
  this.apiUrl = infra.apiUrl;
48
+ this.executionRole = infra.executionRole;
46
49
  }
47
50
 
48
51
  static async create(scope: Construct, id: string, props: BlocksStackProps) {
@@ -103,6 +106,28 @@ export class Scope extends Construct {
103
106
  return ((globalThis as any).CURRENT_BLOCKS_STACK as { handler: cdk.aws_lambda_nodejs.NodejsFunction }).handler;
104
107
  }
105
108
 
109
+ /**
110
+ * The shared IAM role assumed by all Blocks compute. Building Blocks grant
111
+ * their permissions to this role instead of to an individual function's
112
+ * auto-role. CDK's `grant*()` / `addToPrincipalPolicy()` route those grants
113
+ * to the role's default (inline) policy — exactly where they landed on the
114
+ * auto-generated role before.
115
+ *
116
+ * Resolves the same way as {@link handler}: walk up to the owning
117
+ * BlocksStack/BlocksBackend, falling back to the ambient stack.
118
+ */
119
+ get executionRole(): cdk.aws_iam.IRole {
120
+ let current: Construct = this;
121
+ while (current.node.scope) {
122
+ current = current.node.scope as Construct;
123
+ if (current instanceof BlocksStack || current instanceof BlocksBackend) {
124
+ return current.executionRole;
125
+ }
126
+ }
127
+ // Fallback to globalThis for backward compatibility
128
+ return ((globalThis as any).CURRENT_BLOCKS_STACK as { executionRole: cdk.aws_iam.IRole }).executionRole;
129
+ }
130
+
106
131
  get fullId(): string {
107
132
  return computeScopeFullId(this);
108
133
  }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.17';
2
+ export const CORE_VERSION = '0.1.18';