@mettlecast/domain-cdk-packer 0.2.60 → 0.2.62

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.
Files changed (30) hide show
  1. package/dist/DomainStack.d.ts +5 -0
  2. package/dist/DomainStack.js +145 -67
  3. package/dist/__tests__/action-construct.test.d.ts +1 -0
  4. package/dist/__tests__/action-construct.test.js +159 -0
  5. package/dist/__tests__/domain-stack.test.js +315 -15
  6. package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
  7. package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
  8. package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
  9. package/dist/__tests__/grouped-lambda-factory.test.js +146 -0
  10. package/dist/__tests__/lambda-factory.test.js +7 -7
  11. package/dist/__tests__/registry.test.js +111 -4
  12. package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
  13. package/dist/__tests__/security-assertion-aspect.test.js +222 -0
  14. package/dist/aspects/index.d.ts +4 -0
  15. package/dist/aspects/index.js +2 -0
  16. package/dist/aspects/security-assertion-aspect.d.ts +161 -0
  17. package/dist/aspects/security-assertion-aspect.js +226 -0
  18. package/dist/constructs/action-construct.d.ts +16 -6
  19. package/dist/constructs/action-construct.js +10 -11
  20. package/dist/constructs/api-construct.d.ts +10 -2
  21. package/dist/constructs/api-construct.js +25 -5
  22. package/dist/grouped-lambda-factory.d.ts +69 -1
  23. package/dist/grouped-lambda-factory.js +98 -10
  24. package/dist/iam/iam-policy-builder.js +0 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +1 -1
  27. package/dist/pack-domain.d.ts +2 -0
  28. package/dist/pack-domain.js +9 -3
  29. package/dist/registry.d.ts +98 -47
  30. package/package.json +1 -1
@@ -82,6 +82,11 @@ export interface DomainStackProps extends cdk.StackProps {
82
82
  * Defaults to the second segment of the stack name; falls back to `dev` when not derivable.
83
83
  */
84
84
  envCode?: string;
85
+ /**
86
+ * When true, disables the auto-generated per-domain CloudWatch dashboard.
87
+ * Existing dashboards will be removed on the next CDK deploy.
88
+ */
89
+ disableCloudWatchDashboards?: boolean;
85
90
  }
86
91
  export declare class DomainStack extends cdk.Stack {
87
92
  /** Shared HTTP API for routing API and webhook requests. */
@@ -1,7 +1,5 @@
1
1
  import * as cdk from 'aws-cdk-lib';
2
2
  import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
3
- import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
4
- import * as apigwv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
5
3
  import * as events from 'aws-cdk-lib/aws-events';
6
4
  import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
7
5
  import * as sqs from 'aws-cdk-lib/aws-sqs';
@@ -20,12 +18,9 @@ import { WafConstruct } from './constructs/waf-construct.js';
20
18
  import { AlarmConstruct } from './constructs/alarm-construct.js';
21
19
  import { CanaryConstruct } from './constructs/canary-construct.js';
22
20
  import { domainResourceName } from './naming.js';
23
- /**
24
- * Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
25
- * Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
26
- */
21
+ import { SecurityAssertionAspect } from './aspects/security-assertion-aspect.js';
27
22
  /** L1 helper: adds a CfnRoute + CfnIntegration to an existing HTTP API. */
28
- function addRouteToApi(scope, fn, path, methods, apiId) {
23
+ function addRouteToApi(scope, fn, path, methods, apiId, auth) {
29
24
  const id = path.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_/, 'api') + '_' + methods[0].toLowerCase();
30
25
  const integ = new apigwv2.CfnIntegration(scope, 'Integ_' + id, {
31
26
  apiId,
@@ -37,12 +32,24 @@ function addRouteToApi(scope, fn, path, methods, apiId) {
37
32
  principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
38
33
  sourceArn: 'arn:aws:execute-api:' + cdk.Aws.REGION + ':' + cdk.Aws.ACCOUNT_ID + ':' + apiId + '/*',
39
34
  });
40
- const routeKey = methods[0] === apigwv2.HttpMethod.GET ? 'GET ' + path : 'POST ' + path;
41
- new apigwv2.CfnRoute(scope, 'Route_' + id, {
42
- apiId,
43
- routeKey,
44
- target: 'integrations/' + integ.ref,
45
- });
35
+ // Use the actual HTTP method (supports GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS/ANY).
36
+ // Previous implementation only handled GET vs POST and silently coerced every
37
+ // other method to POST, breaking PUT/PATCH/DELETE routes.
38
+ const routeKey = `${methods[0]} ${path}`;
39
+ const routeProps = auth
40
+ ? {
41
+ apiId,
42
+ routeKey,
43
+ target: 'integrations/' + integ.ref,
44
+ authorizationType: auth.authorizationType,
45
+ authorizerId: auth.authorizerId,
46
+ }
47
+ : {
48
+ apiId,
49
+ routeKey,
50
+ target: 'integrations/' + integ.ref,
51
+ };
52
+ new apigwv2.CfnRoute(scope, 'Route_' + id, routeProps);
46
53
  }
47
54
  export class DomainStack extends cdk.Stack {
48
55
  /** Shared HTTP API for routing API and webhook requests. */
@@ -57,7 +64,7 @@ export class DomainStack extends cdk.Stack {
57
64
  */
58
65
  constructor(scope, id, props) {
59
66
  super(scope, id, props);
60
- const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
67
+ const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials, disableCloudWatchDashboards } = props;
61
68
  const vpc = props.vpc ?? (props.vpcId
62
69
  ? ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
63
70
  vpcId: props.vpcId,
@@ -88,15 +95,53 @@ export class DomainStack extends cdk.Stack {
88
95
  ...(allowCredentials ? { allowCredentials: true } : {}),
89
96
  },
90
97
  });
91
- // Wire Cognito JWT authorizer when user pool is provided
98
+ // Wire Cognito JWT authorizer when user pool is provided.
99
+ //
100
+ // We use the L2 `HttpAuthorizer` (not `HttpJwtAuthorizer`) because it eagerly
101
+ // creates the underlying `CfnAuthorizer` and exposes `authorizerId` as a
102
+ // concrete string. `HttpJwtAuthorizer` lazily binds through `bind()` and
103
+ // throws if `authorizerId` is read before binding — that prevented the
104
+ // existing L1 `addRouteToApi` from ever attaching an authorizer, which is
105
+ // the bug Wave 4 Task 4.1 fixes.
92
106
  let jwtAuthorizer;
93
107
  if ((userPoolId || userPoolArn) && userPoolClientId) {
94
108
  const region = cdk.Stack.of(this).region;
95
109
  const resolvedUserPoolId = userPoolId ?? cdk.Fn.select(1, cdk.Fn.split('/', userPoolArn));
96
- jwtAuthorizer = new apigwv2Authorizers.HttpJwtAuthorizer('CognitoAuthorizer', `https://cognito-idp.${region}.amazonaws.com/${resolvedUserPoolId}`, {
110
+ jwtAuthorizer = new apigwv2.HttpAuthorizer(this, 'CognitoAuthorizer', {
111
+ httpApi: this.httpApi,
112
+ authorizerName: 'CognitoAuthorizer',
113
+ type: apigwv2.HttpAuthorizerType.JWT,
114
+ identitySource: ['$request.header.Authorization'],
97
115
  jwtAudience: [userPoolClientId],
116
+ jwtIssuer: `https://cognito-idp.${region}.amazonaws.com/${resolvedUserPoolId}`,
98
117
  });
99
118
  }
119
+ // Pre-resolve authorizer metadata for `addRouteToApi` — `CfnRoute` consumes
120
+ // a concrete `{ authorizerId, authorizationType }` tuple, not the L2 authorizer.
121
+ const jwtAuth = jwtAuthorizer
122
+ ? { authorizerId: jwtAuthorizer.authorizerId, authorizationType: apigwv2.HttpAuthorizerType.JWT }
123
+ : undefined;
124
+ // Determine whether any route in this domain requires a JWT authorizer.
125
+ // If so and `jwtAuth` is undefined, emit a synth-time error so missing Cognito
126
+ // configuration fails fast instead of producing an unauthenticated route.
127
+ //
128
+ // Issue #4689: only API-exposed actions (`exposure.auth === 'required'`)
129
+ // contribute to this check. Legacy `registry.apis` rows are always
130
+ // empty in fresh registries, so checking them would never fire.
131
+ const actionNeedsJwt = registry.actions.some(a => a.exposure?.type === 'api' && a.exposure.auth === 'required');
132
+ const anyRouteRequiresJwt = actionNeedsJwt;
133
+ if (anyRouteRequiresJwt && !jwtAuth) {
134
+ cdk.Annotations.of(this).addError(`[DomainStack] Domain "${domainId}" declares one or more JWT-protected routes ` +
135
+ `(action.exposure.auth='required') but no Cognito user pool is ` +
136
+ `configured. Provide userPoolArn/userPoolId AND userPoolClientId to DomainStackProps.`);
137
+ }
138
+ // Issue #4662 Task D — deployment-time security assertions. The aspect
139
+ // walks the synthesised tree after the stack has been assembled and
140
+ // re-validates the same invariants the CLI checks at build time. It
141
+ // catches regressions where a route was silently synthesised without
142
+ // an authorizer (e.g. a feature flag skipped the wiring), or where an
143
+ // action Lambda was attached to a Function URL outside the registry.
144
+ cdk.Aspects.of(this).add(new SecurityAssertionAspect({ registry }));
100
145
  // Optional WAF
101
146
  if (enableWaf) {
102
147
  new WafConstruct(this, 'Waf', { domainId, namePrefix: resourceBaseName, httpApi: this.httpApi });
@@ -138,12 +183,30 @@ export class DomainStack extends cdk.Stack {
138
183
  const lambdas = [];
139
184
  const byId = new Map();
140
185
  for (const group of splitByOutboundAccess(entries)) {
141
- const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated') || primitiveType === 'api';
186
+ // Force dedicated mode for action groups that contain any API-exposed
187
+ // action — the per-entry adapter override only takes effect in
188
+ // dedicated mode (grouped mode uses a single TIB_HANDLER_MAP dispatcher
189
+ // which assumes a uniform adapter across all entries). We detect
190
+ // api-exposed actions by their `adapter` override (set by the caller
191
+ // in the action block below), since the entry shape is intentionally
192
+ // primitive-agnostic.
193
+ const hasApiExposedAction = primitiveType === 'action'
194
+ && group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
195
+ const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
196
+ || hasApiExposedAction;
142
197
  const groupLambdas = createGroupedLambdas(this, {
143
198
  domainId,
144
199
  domainRoot: registry.domainRoot,
145
200
  primitiveType,
146
- handlerEntries: group.entries.map(entry => ({ id: entry.id, handlerFile: entry.handlerFile })),
201
+ handlerEntries: group.entries.map(entry => {
202
+ const handlerEntry = {
203
+ id: entry.id,
204
+ handlerFile: entry.handlerFile,
205
+ };
206
+ if (entry.adapter)
207
+ handlerEntry.adapter = entry.adapter;
208
+ return handlerEntry;
209
+ }),
147
210
  environment,
148
211
  eventBusArn,
149
212
  dedicated,
@@ -205,7 +268,6 @@ export class DomainStack extends cdk.Stack {
205
268
  });
206
269
  environment['DOMAIN_TENANT_ROLE_ARN'] = tenantScopedRole.roleArn;
207
270
  // Pre-declare lambda group variables
208
- let apiLambdas = [];
209
271
  let webhookLambdas = [];
210
272
  let subscriberLambdas = [];
211
273
  let scheduleLambdas = [];
@@ -214,45 +276,18 @@ export class DomainStack extends cdk.Stack {
214
276
  // Track all DLQs for alarm construct
215
277
  const allDlqs = [];
216
278
  // Deploy API endpoints as grouped Lambdas
217
- if (registry.apis.length > 0) {
218
- const apiHandlers = createPrimitiveHandlers(registry.apis, 'api');
219
- const apiLambdaById = apiHandlers.byId;
220
- apiLambdas = apiHandlers.lambdas;
221
- this.lambdaArns[`${domainId}-api`] = apiLambdas[0].functionArn;
222
- // Wire each API Lambda to the HttpApi
223
- const iamPolicies = iamBuilder.forApi();
224
- apiLambdas.forEach((fn) => {
225
- iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
226
- });
227
- // Add dbSecretArn grant if provided
228
- if (dbSecretArn) {
229
- apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
230
- actions: ['secretsmanager:GetSecretValue'],
231
- resources: [dbSecretArn],
232
- })));
233
- }
234
- // Add routes for each API entry
235
- for (const api of registry.apis) {
236
- const fn = apiLambdaById.get(api.id);
237
- const apiRouteBase = {
238
- path: api.path,
239
- methods: [toHttpMethod(api.method)],
240
- integration: new apigwv2integrations.HttpLambdaIntegration(`Apis${toPascalCase(api.id)}Integration`, fn),
241
- };
242
- let apiRoute;
243
- if (api.authType === 'jwt' && jwtAuthorizer) {
244
- apiRoute = { ...apiRouteBase, authorizer: jwtAuthorizer };
245
- }
246
- else if (api.authType === 'api-key') {
247
- cdk.Annotations.of(this).addWarning(`[DomainStack] API "${api.id}" uses authType "api-key" which is not supported on HTTP API v2 — route is UNAUTHENTICATED. Switch to "jwt" or implement a Lambda authorizer.`);
248
- apiRoute = apiRouteBase;
249
- }
250
- else {
251
- apiRoute = apiRouteBase;
252
- }
253
- addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId);
254
- }
255
- }
279
+ //
280
+ // Issue #4689: the legacy `defineApi` primitive was removed. The
281
+ // canonical HTTP endpoint surface is `actions[]` with
282
+ // `exposure.type === 'api'`. Each API-exposed action is wrapped with
283
+ // `createExposedActionApiHandler` (see grouped-lambda-factory) and
284
+ // mounted on the HTTP API in the action-routing loop below. The
285
+ // legacy `registry.apis` block has been removed because the builder
286
+ // no longer populates that slot in fresh registries.
287
+ //
288
+ // We intentionally do NOT iterate `registry.apis` here; doing so
289
+ // would re-introduce legacy routing that is no longer reachable
290
+ // from the action-first source-of-truth.
256
291
  // Deploy webhooks as grouped Lambdas
257
292
  if (registry.webhooks.length > 0) {
258
293
  // Create shared dedupe table for webhooks
@@ -420,7 +455,27 @@ export class DomainStack extends cdk.Stack {
420
455
  }
421
456
  // Deploy callable actions as grouped Lambdas
422
457
  if (registry.actions.length > 0) {
423
- const actionHandlers = createPrimitiveHandlers(registry.actions, 'action');
458
+ // Map action entries to NetworkedRegistryEntry so we can attach a per-entry
459
+ // adapter override. API-exposed actions must use `createExposedActionApiHandler`
460
+ // because they receive raw HTTP API v2 events; internal actions use the
461
+ // default `createActionLambdaHandler` (envelope-based invocation through
462
+ // `ctx.actions`).
463
+ const actionEntries = registry.actions.map((action) => {
464
+ const entry = {
465
+ id: action.id,
466
+ handlerFile: action.handlerFile,
467
+ };
468
+ if (action.outboundAccess !== undefined)
469
+ entry.outboundAccess = action.outboundAccess;
470
+ if (action.deployment !== undefined)
471
+ entry.deployment = action.deployment;
472
+ if (action.exposure?.type === 'api') {
473
+ entry.adapter = 'createExposedActionApiHandler';
474
+ }
475
+ return entry;
476
+ });
477
+ const actionHandlers = createPrimitiveHandlers(actionEntries, 'action');
478
+ const actionLambdaById = actionHandlers.byId;
424
479
  actionLambdas = actionHandlers.lambdas;
425
480
  this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
426
481
  const iamPolicies = iamBuilder.forAction([
@@ -436,10 +491,22 @@ export class DomainStack extends cdk.Stack {
436
491
  resources: [dbSecretArn],
437
492
  })));
438
493
  }
494
+ // Generate API Gateway routes for actions whose exposure is `api`.
495
+ //
496
+ // `exposure.type === 'internal'` actions are intentionally NOT routed —
497
+ // they are reachable only through `ctx.actions` and exposing them would
498
+ // silently bypass the `backendAccess` permission gate.
499
+ for (const action of registry.actions) {
500
+ if (action.exposure?.type !== 'api')
501
+ continue;
502
+ const fn = actionLambdaById.get(action.id);
503
+ const routeAuth = action.exposure.auth === 'required' ? jwtAuth : undefined;
504
+ addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
505
+ }
439
506
  }
440
507
  // Grant all domain Lambdas read/write access to the per-domain table and bucket
441
508
  const allDomainLambdas = [
442
- ...apiLambdas, ...webhookLambdas, ...subscriberLambdas,
509
+ ...webhookLambdas, ...subscriberLambdas,
443
510
  ...scheduleLambdas, ...jobLambdas, ...actionLambdas,
444
511
  ];
445
512
  for (const fn of allDomainLambdas) {
@@ -509,19 +576,30 @@ export class DomainStack extends cdk.Stack {
509
576
  addRouteToApi(this, healthConstruct.healthFn, `/${domainId}/_health`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
510
577
  addRouteToApi(this, healthConstruct.readyFn, `/${domainId}/_ready`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
511
578
  // Create CloudWatch dashboard for all primitives
579
+ //
580
+ // Issue #4689: HTTP endpoints are now action entries with
581
+ // `exposure.type === 'api'`. We tag those actions with the
582
+ // `primitiveClass: 'api'` so the dashboard can still group them
583
+ // separately from internal-only actions.
512
584
  const allEndpoints = [
513
- ...registry.apis.map(a => ({ id: a.id, primitiveClass: 'api' })),
514
- ...registry.actions.map(a => ({ id: a.id, primitiveClass: 'action' })),
585
+ ...registry.actions
586
+ .filter(a => a.exposure?.type === 'api')
587
+ .map(a => ({ id: a.id, primitiveClass: 'api' })),
588
+ ...registry.actions
589
+ .filter(a => a.exposure?.type !== 'api')
590
+ .map(a => ({ id: a.id, primitiveClass: 'action' })),
515
591
  ...registry.subscribers.map(s => ({ id: s.id, primitiveClass: 'subscriber' })),
516
592
  ...registry.jobs.map(j => ({ id: j.id, primitiveClass: 'job' })),
517
593
  ...registry.webhooks.map(w => ({ id: w.id, primitiveClass: 'webhook' })),
518
594
  ...registry.schedules.map(sc => ({ id: sc.id, primitiveClass: 'schedule' })),
519
595
  ];
520
- new DashboardConstruct(this, 'Dashboard', {
521
- domainId,
522
- envCode: this.stackName.split('-')[1] ?? 'dev',
523
- endpoints: allEndpoints,
524
- });
596
+ if (!disableCloudWatchDashboards) {
597
+ new DashboardConstruct(this, 'Dashboard', {
598
+ domainId,
599
+ envCode: this.stackName.split('-')[1] ?? 'dev',
600
+ endpoints: allEndpoints,
601
+ });
602
+ }
525
603
  new cdk.CfnOutput(this, 'DomainTableName', {
526
604
  value: domainTable.tableName,
527
605
  description: 'Per-domain DynamoDB table name',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as cdk from 'aws-cdk-lib';
3
+ import { Template } from 'aws-cdk-lib/assertions';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { ActionConstruct } from '../constructs/action-construct.js';
7
+ import { LambdaFactory } from '../lambda-factory.js';
8
+ import { IamPolicyBuilder } from '../iam/iam-policy-builder.js';
9
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-action-construct');
10
+ const baseRegistry = {
11
+ schemaVersion: '1',
12
+ domainRoot: DOMAIN_ROOT,
13
+ domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
14
+ webhooks: [], subscribers: [], schedules: [], jobs: [],
15
+ actions: [], integrations: [], events: [],
16
+ };
17
+ beforeAll(() => {
18
+ const handlerDir = path.join(DOMAIN_ROOT, 'src', 'actions');
19
+ fs.mkdirSync(handlerDir, { recursive: true });
20
+ fs.writeFileSync(path.join(handlerDir, 'noop.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
21
+ fs.writeFileSync(path.join(handlerDir, 'workspace.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
22
+ });
23
+ afterAll(() => {
24
+ fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
25
+ });
26
+ /**
27
+ * Wave 4 Task 4.2 (#4619):
28
+ * Action Lambdas must never be exposed directly through a Lambda Function URL.
29
+ * External reachability is granted ONLY via API Gateway routes synthesized from
30
+ * the action's `exposure` field. Function URLs with `authType: NONE` would
31
+ * create public, unauthenticated endpoints that bypass auth and tenancy.
32
+ */
33
+ describe('ActionConstruct (Wave 4 Task 4.2 — no public Function URLs)', () => {
34
+ const buildStack = (actions) => {
35
+ const app = new cdk.App();
36
+ const stack = new cdk.Stack(app, 'TestActionStack');
37
+ const registry = { ...baseRegistry, actions };
38
+ const factory = new LambdaFactory({ scope: stack, registry, domainRoot: registry.domainRoot });
39
+ const iamBuilder = new IamPolicyBuilder();
40
+ const construct = new ActionConstruct(stack, 'Actions', { registry, lambdaFactory: factory, iamBuilder });
41
+ return { stack, template: Template.fromStack(stack), construct };
42
+ };
43
+ it('synthesises one Lambda per action', () => {
44
+ const { template } = buildStack([
45
+ { id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
46
+ ]);
47
+ const lambdas = Object.entries(template.findResources('AWS::Lambda::Function'))
48
+ .filter(([id]) => !id.startsWith('LogRetention'));
49
+ expect(lambdas).toHaveLength(1);
50
+ expect(lambdas[0][0]).toContain('NoopFn');
51
+ });
52
+ it('does NOT create a Lambda::Url resource for any action', () => {
53
+ const { template } = buildStack([
54
+ { id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
55
+ ]);
56
+ template.resourceCountIs('AWS::Lambda::Url', 0);
57
+ });
58
+ it('does NOT create a Lambda::Url even when legacy visibility === "workspace"', () => {
59
+ // Legacy entries with the deprecated `visibility: 'workspace'` must NOT
60
+ // resurrect Function URL exposure now that the action-first migration is in place.
61
+ const { template } = buildStack([
62
+ {
63
+ id: 'workspace-action',
64
+ kind: 'action',
65
+ handlerFile: 'src/actions/workspace.ts',
66
+ backendAccess: 'domain',
67
+ exposure: { type: 'internal' },
68
+ idempotent: false,
69
+ visibility: 'workspace',
70
+ },
71
+ ]);
72
+ template.resourceCountIs('AWS::Lambda::Url', 0);
73
+ const lambdas = Object.entries(template.findResources('AWS::Lambda::Function'))
74
+ .filter(([id]) => !id.startsWith('LogRetention'));
75
+ expect(lambdas).toHaveLength(1);
76
+ expect(lambdas[0][0]).toContain('WorkspaceActionFn');
77
+ });
78
+ it('does NOT create a Lambda::Url when an action exposes via API Gateway (exposure.type === "api")', () => {
79
+ // API-exposed actions are routed by DomainStack via API Gateway, not by Function URL.
80
+ const { template } = buildStack([
81
+ {
82
+ id: 'api-action',
83
+ kind: 'action',
84
+ handlerFile: 'src/actions/noop.ts',
85
+ backendAccess: 'domain',
86
+ exposure: { type: 'api', path: '/v1/example', method: 'POST', auth: 'required', tenancy: 'required' },
87
+ idempotent: false,
88
+ },
89
+ ]);
90
+ template.resourceCountIs('AWS::Lambda::Url', 0);
91
+ });
92
+ it('does NOT attach any Lambda::Permission that allows public (apigateway) principal invocation', () => {
93
+ // Defensive: even without a Function URL, the construct must not add
94
+ // resource-based Lambda permissions that would let API Gateway invoke
95
+ // the function without going through the synthesized routes in DomainStack.
96
+ const { template, construct } = buildStack([
97
+ { id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
98
+ ]);
99
+ expect(construct.functions.size).toBe(1);
100
+ template.resourceCountIs('AWS::Lambda::Url', 0);
101
+ const permissions = Object.values(template.findResources('AWS::Lambda::Permission'));
102
+ // No Function URL permission allowed
103
+ const hasUrlPermission = permissions.some(p => p.Properties.FunctionUrlAuthType !== undefined);
104
+ expect(hasUrlPermission).toBe(false);
105
+ });
106
+ });
107
+ /**
108
+ * Tenant-scoped storage IAM (defence-in-depth, Wave 4 Task 4.2).
109
+ *
110
+ * Verifies the IamPolicyBuilder.forTenantScopedStorage output uses
111
+ * PrincipalTag conditions to restrict DynamoDB LeadingKeys and S3 object
112
+ * prefixes to the calling session's tenantId.
113
+ */
114
+ describe('IamPolicyBuilder.forTenantScopedStorage (defence-in-depth)', () => {
115
+ const params = {
116
+ tableArn: 'arn:aws:dynamodb:eu-north-1:123456789012:table/test-domain',
117
+ bucketArn: 'arn:aws:s3:::test-domain',
118
+ };
119
+ it('restricts DynamoDB LeadingKeys to ${aws:PrincipalTag/tenantId}', () => {
120
+ const builder = new IamPolicyBuilder();
121
+ const statements = builder.forTenantScopedStorage(params);
122
+ const ddb = statements.find(s => (s.actions ?? []).some(a => a.startsWith('dynamodb:')));
123
+ expect(ddb).toBeDefined();
124
+ expect(ddb.resources).toContain(params.tableArn);
125
+ const leadingKeys = ddb.conditions?.['ForAllValues:StringEquals']?.['dynamodb:LeadingKeys'];
126
+ expect(leadingKeys).toEqual(['${aws:PrincipalTag/tenantId}']);
127
+ });
128
+ it('restricts S3 object operations to ${aws:PrincipalTag/tenantId}/* prefix', () => {
129
+ const builder = new IamPolicyBuilder();
130
+ const statements = builder.forTenantScopedStorage(params);
131
+ const s3Objects = statements.find(s => (s.actions ?? []).some(a => a === 's3:GetObject' || a === 's3:PutObject' || a === 's3:DeleteObject'));
132
+ expect(s3Objects).toBeDefined();
133
+ expect(s3Objects.resources).toEqual([
134
+ `${params.bucketArn}/\${aws:PrincipalTag/tenantId}/*`,
135
+ ]);
136
+ });
137
+ it('restricts S3:ListBucket to the current tenant prefix via s3:prefix condition', () => {
138
+ const builder = new IamPolicyBuilder();
139
+ const statements = builder.forTenantScopedStorage(params);
140
+ const listBucket = statements.find(s => (s.actions ?? []).includes('s3:ListBucket'));
141
+ expect(listBucket).toBeDefined();
142
+ expect(listBucket.resources).toEqual([params.bucketArn]);
143
+ const prefixCondition = listBucket.conditions
144
+ ?.StringLike?.['s3:prefix'];
145
+ expect(prefixCondition).toEqual(['${aws:PrincipalTag/tenantId}/*']);
146
+ });
147
+ it('does NOT grant bucket-wide S3 access (no unconditional ${bucketArn} resource for object ops)', () => {
148
+ const builder = new IamPolicyBuilder();
149
+ const statements = builder.forTenantScopedStorage(params);
150
+ for (const s of statements) {
151
+ const acts = (s.actions ?? []);
152
+ const isObjectOp = acts.some(a => a === 's3:GetObject' || a === 's3:PutObject' || a === 's3:DeleteObject');
153
+ if (isObjectOp) {
154
+ // The only resource granted must be the tenant-scoped prefix, not the bare bucket.
155
+ expect(s.resources).not.toContain(params.bucketArn);
156
+ }
157
+ }
158
+ });
159
+ });