@mettlecast/domain-cdk-packer 0.2.61 → 0.2.63
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.
- package/dist/DomainStack.d.ts +5 -0
- package/dist/DomainStack.js +38 -41
- package/dist/__tests__/action-construct.test.js +1 -1
- package/dist/__tests__/domain-stack.test.js +104 -37
- package/dist/__tests__/grouped-lambda-factory.test.js +5 -6
- package/dist/__tests__/lambda-factory.test.js +7 -7
- package/dist/__tests__/registry.test.js +3 -4
- package/dist/__tests__/security-assertion-aspect.test.js +36 -14
- package/dist/aspects/security-assertion-aspect.d.ts +6 -36
- package/dist/aspects/security-assertion-aspect.js +6 -77
- package/dist/constructs/api-construct.d.ts +10 -2
- package/dist/constructs/api-construct.js +25 -5
- package/dist/grouped-lambda-factory.d.ts +1 -2
- package/dist/grouped-lambda-factory.js +1 -2
- package/dist/iam/iam-policy-builder.js +0 -2
- package/dist/index.d.ts +1 -1
- package/dist/pack-domain.d.ts +2 -0
- package/dist/pack-domain.js +9 -3
- package/dist/registry.d.ts +2 -59
- package/package.json +1 -1
package/dist/DomainStack.d.ts
CHANGED
|
@@ -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. */
|
package/dist/DomainStack.js
CHANGED
|
@@ -64,7 +64,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
64
64
|
*/
|
|
65
65
|
constructor(scope, id, props) {
|
|
66
66
|
super(scope, id, props);
|
|
67
|
-
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;
|
|
68
68
|
const vpc = props.vpc ?? (props.vpcId
|
|
69
69
|
? ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
|
|
70
70
|
vpcId: props.vpcId,
|
|
@@ -124,12 +124,15 @@ export class DomainStack extends cdk.Stack {
|
|
|
124
124
|
// Determine whether any route in this domain requires a JWT authorizer.
|
|
125
125
|
// If so and `jwtAuth` is undefined, emit a synth-time error so missing Cognito
|
|
126
126
|
// configuration fails fast instead of producing an unauthenticated route.
|
|
127
|
-
|
|
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.
|
|
128
131
|
const actionNeedsJwt = registry.actions.some(a => a.exposure?.type === 'api' && a.exposure.auth === 'required');
|
|
129
|
-
const anyRouteRequiresJwt =
|
|
132
|
+
const anyRouteRequiresJwt = actionNeedsJwt;
|
|
130
133
|
if (anyRouteRequiresJwt && !jwtAuth) {
|
|
131
134
|
cdk.Annotations.of(this).addError(`[DomainStack] Domain "${domainId}" declares one or more JWT-protected routes ` +
|
|
132
|
-
`(
|
|
135
|
+
`(action.exposure.auth='required') but no Cognito user pool is ` +
|
|
133
136
|
`configured. Provide userPoolArn/userPoolId AND userPoolClientId to DomainStackProps.`);
|
|
134
137
|
}
|
|
135
138
|
// Issue #4662 Task D — deployment-time security assertions. The aspect
|
|
@@ -190,7 +193,6 @@ export class DomainStack extends cdk.Stack {
|
|
|
190
193
|
const hasApiExposedAction = primitiveType === 'action'
|
|
191
194
|
&& group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
|
|
192
195
|
const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
|
|
193
|
-
|| primitiveType === 'api'
|
|
194
196
|
|| hasApiExposedAction;
|
|
195
197
|
const groupLambdas = createGroupedLambdas(this, {
|
|
196
198
|
domainId,
|
|
@@ -266,7 +268,6 @@ export class DomainStack extends cdk.Stack {
|
|
|
266
268
|
});
|
|
267
269
|
environment['DOMAIN_TENANT_ROLE_ARN'] = tenantScopedRole.roleArn;
|
|
268
270
|
// Pre-declare lambda group variables
|
|
269
|
-
let apiLambdas = [];
|
|
270
271
|
let webhookLambdas = [];
|
|
271
272
|
let subscriberLambdas = [];
|
|
272
273
|
let scheduleLambdas = [];
|
|
@@ -275,33 +276,18 @@ export class DomainStack extends cdk.Stack {
|
|
|
275
276
|
// Track all DLQs for alarm construct
|
|
276
277
|
const allDlqs = [];
|
|
277
278
|
// Deploy API endpoints as grouped Lambdas
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
291
|
-
actions: ['secretsmanager:GetSecretValue'],
|
|
292
|
-
resources: [dbSecretArn],
|
|
293
|
-
})));
|
|
294
|
-
}
|
|
295
|
-
// Add routes for each API entry
|
|
296
|
-
for (const api of registry.apis) {
|
|
297
|
-
const fn = apiLambdaById.get(api.id);
|
|
298
|
-
if (api.authType === 'api-key') {
|
|
299
|
-
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.`);
|
|
300
|
-
}
|
|
301
|
-
const routeAuth = api.authType === 'jwt' ? jwtAuth : undefined;
|
|
302
|
-
addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId, routeAuth);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
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.
|
|
305
291
|
// Deploy webhooks as grouped Lambdas
|
|
306
292
|
if (registry.webhooks.length > 0) {
|
|
307
293
|
// Create shared dedupe table for webhooks
|
|
@@ -520,7 +506,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
520
506
|
}
|
|
521
507
|
// Grant all domain Lambdas read/write access to the per-domain table and bucket
|
|
522
508
|
const allDomainLambdas = [
|
|
523
|
-
...
|
|
509
|
+
...webhookLambdas, ...subscriberLambdas,
|
|
524
510
|
...scheduleLambdas, ...jobLambdas, ...actionLambdas,
|
|
525
511
|
];
|
|
526
512
|
for (const fn of allDomainLambdas) {
|
|
@@ -590,19 +576,30 @@ export class DomainStack extends cdk.Stack {
|
|
|
590
576
|
addRouteToApi(this, healthConstruct.healthFn, `/${domainId}/_health`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
|
|
591
577
|
addRouteToApi(this, healthConstruct.readyFn, `/${domainId}/_ready`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
|
|
592
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.
|
|
593
584
|
const allEndpoints = [
|
|
594
|
-
...registry.
|
|
595
|
-
|
|
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' })),
|
|
596
591
|
...registry.subscribers.map(s => ({ id: s.id, primitiveClass: 'subscriber' })),
|
|
597
592
|
...registry.jobs.map(j => ({ id: j.id, primitiveClass: 'job' })),
|
|
598
593
|
...registry.webhooks.map(w => ({ id: w.id, primitiveClass: 'webhook' })),
|
|
599
594
|
...registry.schedules.map(sc => ({ id: sc.id, primitiveClass: 'schedule' })),
|
|
600
595
|
];
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
596
|
+
if (!disableCloudWatchDashboards) {
|
|
597
|
+
new DashboardConstruct(this, 'Dashboard', {
|
|
598
|
+
domainId,
|
|
599
|
+
envCode: this.stackName.split('-')[1] ?? 'dev',
|
|
600
|
+
endpoints: allEndpoints,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
606
603
|
new cdk.CfnOutput(this, 'DomainTableName', {
|
|
607
604
|
value: domainTable.tableName,
|
|
608
605
|
description: 'Per-domain DynamoDB table name',
|
|
@@ -11,7 +11,7 @@ const baseRegistry = {
|
|
|
11
11
|
schemaVersion: '1',
|
|
12
12
|
domainRoot: DOMAIN_ROOT,
|
|
13
13
|
domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
|
|
14
|
-
|
|
14
|
+
webhooks: [], subscribers: [], schedules: [], jobs: [],
|
|
15
15
|
actions: [], integrations: [], events: [],
|
|
16
16
|
};
|
|
17
17
|
beforeAll(() => {
|
|
@@ -22,32 +22,44 @@ const minimalRegistry = {
|
|
|
22
22
|
schemaVersion: '1',
|
|
23
23
|
domainRoot: DOMAIN_ROOT,
|
|
24
24
|
domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
|
|
25
|
-
|
|
25
|
+
// Issue #4689: the canonical HTTP endpoint surface is actions[] with
|
|
26
|
+
// exposure.type === 'api'. The minimal registry now declares its
|
|
27
|
+
// single test endpoint as an action; apis is always empty.
|
|
28
|
+
webhooks: [],
|
|
29
|
+
subscribers: [],
|
|
30
|
+
schedules: [],
|
|
31
|
+
jobs: [],
|
|
32
|
+
actions: [
|
|
26
33
|
{
|
|
27
34
|
id: 'get-users',
|
|
28
|
-
kind: '
|
|
35
|
+
kind: 'action',
|
|
29
36
|
handlerFile: 'src/handlers/get-users.ts',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
backendAccess: 'domain',
|
|
38
|
+
exposure: {
|
|
39
|
+
type: 'api',
|
|
40
|
+
path: '/v1/tenants/{tenantId}/users',
|
|
41
|
+
method: 'GET',
|
|
42
|
+
auth: 'required',
|
|
43
|
+
tenancy: 'required',
|
|
44
|
+
},
|
|
45
|
+
idempotent: false,
|
|
33
46
|
},
|
|
34
47
|
],
|
|
35
|
-
webhooks: [],
|
|
36
|
-
subscribers: [],
|
|
37
|
-
schedules: [],
|
|
38
|
-
jobs: [],
|
|
39
|
-
actions: [],
|
|
40
48
|
integrations: [],
|
|
41
49
|
events: [],
|
|
42
50
|
};
|
|
43
51
|
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
44
52
|
fs.mkdirSync(handlerDir, { recursive: true });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
// Match the grouped-lambda-factory's export-name conversion: kebab-case
|
|
54
|
+
// ids map to camelCase export names (e.g. `get-users` → `getUsers`).
|
|
55
|
+
const toExportName = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
56
|
+
const handlerStub = (id, method, path, auth = 'required', securityException) => `export const ${toExportName(id)} = { id: ${JSON.stringify(id)}, backendAccess: "domain", exposure: { type: "api", path: ${JSON.stringify(path)}, method: ${JSON.stringify(method)}, auth: ${JSON.stringify(auth)}, tenancy: "required"${securityException ? `, securityException: { reason: ${JSON.stringify(securityException)} }` : ''} }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`;
|
|
57
|
+
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), handlerStub('get-users', 'GET', '/v1/tenants/{tenantId}/users'));
|
|
58
|
+
fs.writeFileSync(path.join(handlerDir, 'create-user.ts'), handlerStub('create-user', 'POST', '/v1/tenants/{tenantId}/users'));
|
|
59
|
+
fs.writeFileSync(path.join(handlerDir, 'replace-user.ts'), handlerStub('replace-user', 'PUT', '/v1/tenants/{tenantId}/users/{id}'));
|
|
60
|
+
fs.writeFileSync(path.join(handlerDir, 'patch-user.ts'), handlerStub('patch-user', 'PATCH', '/v1/tenants/{tenantId}/users/{id}'));
|
|
61
|
+
fs.writeFileSync(path.join(handlerDir, 'delete-user.ts'), handlerStub('delete-user', 'DELETE', '/v1/tenants/{tenantId}/users/{id}'));
|
|
62
|
+
fs.writeFileSync(path.join(handlerDir, 'list-things.ts'), handlerStub('list-things', 'GET', '/v1/tenants/{tenantId}/things', 'none', 'public read-only listing'));
|
|
51
63
|
afterAll(() => {
|
|
52
64
|
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
53
65
|
});
|
|
@@ -84,14 +96,22 @@ describe('DomainStack', () => {
|
|
|
84
96
|
AuthorizerType: 'JWT',
|
|
85
97
|
});
|
|
86
98
|
});
|
|
87
|
-
it('no authorizer attached when
|
|
99
|
+
it('no authorizer attached when no JWT-protected actions are declared', () => {
|
|
88
100
|
const app = new cdk.App();
|
|
89
101
|
const registryNoAuth = {
|
|
90
102
|
...minimalRegistry,
|
|
91
|
-
|
|
103
|
+
// Replace the JWT action with an auth=none + securityException
|
|
104
|
+
// action so the authorizer is not required.
|
|
105
|
+
actions: [
|
|
92
106
|
{
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
|
|
108
|
+
backendAccess: 'domain',
|
|
109
|
+
exposure: {
|
|
110
|
+
type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET',
|
|
111
|
+
auth: 'none', tenancy: 'required',
|
|
112
|
+
securityException: { reason: 'public read-only listing' },
|
|
113
|
+
},
|
|
114
|
+
idempotent: false,
|
|
95
115
|
},
|
|
96
116
|
],
|
|
97
117
|
};
|
|
@@ -202,13 +222,43 @@ describe('DomainStack', () => {
|
|
|
202
222
|
*/
|
|
203
223
|
const multiMethodRegistry = {
|
|
204
224
|
...minimalRegistry,
|
|
205
|
-
|
|
206
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
225
|
+
actions: [
|
|
226
|
+
{
|
|
227
|
+
id: 'get-users', kind: 'action', handlerFile: 'src/handlers/get-users.ts',
|
|
228
|
+
backendAccess: 'domain',
|
|
229
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
230
|
+
idempotent: false,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 'create-user', kind: 'action', handlerFile: 'src/handlers/create-user.ts',
|
|
234
|
+
backendAccess: 'domain',
|
|
235
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'POST', auth: 'required', tenancy: 'required' },
|
|
236
|
+
idempotent: false,
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'replace-user', kind: 'action', handlerFile: 'src/handlers/replace-user.ts',
|
|
240
|
+
backendAccess: 'domain',
|
|
241
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PUT', auth: 'required', tenancy: 'required' },
|
|
242
|
+
idempotent: false,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'patch-user', kind: 'action', handlerFile: 'src/handlers/patch-user.ts',
|
|
246
|
+
backendAccess: 'domain',
|
|
247
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PATCH', auth: 'required', tenancy: 'required' },
|
|
248
|
+
idempotent: false,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: 'delete-user', kind: 'action', handlerFile: 'src/handlers/delete-user.ts',
|
|
252
|
+
backendAccess: 'domain',
|
|
253
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'DELETE', auth: 'required', tenancy: 'required' },
|
|
254
|
+
idempotent: false,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
|
|
258
|
+
backendAccess: 'domain',
|
|
259
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET', auth: 'none', tenancy: 'required', securityException: { reason: 'public read-only listing' } },
|
|
260
|
+
idempotent: false,
|
|
261
|
+
},
|
|
212
262
|
],
|
|
213
263
|
};
|
|
214
264
|
it('uses the actual HTTP method for each route key (GET/POST/PUT/PATCH/DELETE)', () => {
|
|
@@ -224,11 +274,14 @@ describe('DomainStack', () => {
|
|
|
224
274
|
const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
|
|
225
275
|
// Every method must round-trip — the previous implementation coerced all
|
|
226
276
|
// non-GET methods to POST, so PUT/PATCH/DELETE were broken.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
expect(routeKeys).toContain('
|
|
231
|
-
expect(routeKeys).toContain('
|
|
277
|
+
// Issue #4689: routes now use the action's exposure.path (which
|
|
278
|
+
// already includes `/v1/tenants/{tenantId}/`) and the canonical
|
|
279
|
+
// tenant placeholder.
|
|
280
|
+
expect(routeKeys).toContain('GET /test-domain/v1/tenants/{tenantId}/users');
|
|
281
|
+
expect(routeKeys).toContain('POST /test-domain/v1/tenants/{tenantId}/users');
|
|
282
|
+
expect(routeKeys).toContain('PUT /test-domain/v1/tenants/{tenantId}/users/{id}');
|
|
283
|
+
expect(routeKeys).toContain('PATCH /test-domain/v1/tenants/{tenantId}/users/{id}');
|
|
284
|
+
expect(routeKeys).toContain('DELETE /test-domain/v1/tenants/{tenantId}/users/{id}');
|
|
232
285
|
});
|
|
233
286
|
it('sets AuthorizationType=JWT and AuthorizerId on jwt-auth routes', () => {
|
|
234
287
|
const app = new cdk.App();
|
|
@@ -242,7 +295,8 @@ describe('DomainStack', () => {
|
|
|
242
295
|
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
243
296
|
const jwtRoutes = Object.values(routes).filter(r => {
|
|
244
297
|
const key = r.Properties.RouteKey;
|
|
245
|
-
|
|
298
|
+
// Issue #4689: paths now include the tenant placeholder.
|
|
299
|
+
return key.includes('/users') && !key.includes('Health') && !key.includes('Ready');
|
|
246
300
|
});
|
|
247
301
|
// Every jwt-auth route must carry AuthorizationType and AuthorizerId.
|
|
248
302
|
expect(jwtRoutes.length).toBeGreaterThan(0);
|
|
@@ -252,7 +306,7 @@ describe('DomainStack', () => {
|
|
|
252
306
|
expect(authorizerId).toBeDefined();
|
|
253
307
|
}
|
|
254
308
|
});
|
|
255
|
-
it('does not attach authorizer to non-jwt routes (
|
|
309
|
+
it('does not attach authorizer to non-jwt routes (auth=none)', () => {
|
|
256
310
|
const app = new cdk.App();
|
|
257
311
|
const stack = new DomainStack(app, 'TestDomainStackUnauthRoute', {
|
|
258
312
|
registry: multiMethodRegistry,
|
|
@@ -262,7 +316,7 @@ describe('DomainStack', () => {
|
|
|
262
316
|
});
|
|
263
317
|
const template = Template.fromStack(stack);
|
|
264
318
|
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
265
|
-
const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/things');
|
|
319
|
+
const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/v1/tenants/{tenantId}/things');
|
|
266
320
|
expect(unauthRoute).toBeDefined();
|
|
267
321
|
expect(unauthRoute.Properties.AuthorizationType).toBeUndefined();
|
|
268
322
|
expect(unauthRoute.Properties.AuthorizerId).toBeUndefined();
|
|
@@ -282,11 +336,24 @@ describe('DomainStack', () => {
|
|
|
282
336
|
.map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
|
|
283
337
|
expect(errorMessages.some(a => a.includes('JWT-protected routes') && a.includes('Cognito'))).toBe(true);
|
|
284
338
|
});
|
|
285
|
-
it('does NOT emit the missing-pool error when
|
|
339
|
+
it('does NOT emit the missing-pool error when no JWT-protected actions are present', () => {
|
|
286
340
|
const app = new cdk.App();
|
|
287
341
|
const noJwtRegistry = {
|
|
288
342
|
...minimalRegistry,
|
|
289
|
-
|
|
343
|
+
// Replace the JWT-protected action with an auth=none + securityException
|
|
344
|
+
// action so the JWT-needs-pool check does not fire.
|
|
345
|
+
actions: [
|
|
346
|
+
{
|
|
347
|
+
id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
|
|
348
|
+
backendAccess: 'domain',
|
|
349
|
+
exposure: {
|
|
350
|
+
type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET',
|
|
351
|
+
auth: 'none', tenancy: 'required',
|
|
352
|
+
securityException: { reason: 'public read-only listing' },
|
|
353
|
+
},
|
|
354
|
+
idempotent: false,
|
|
355
|
+
},
|
|
356
|
+
],
|
|
290
357
|
};
|
|
291
358
|
const stack = new DomainStack(app, 'TestDomainStackNoJwtNeeded', {
|
|
292
359
|
registry: noJwtRegistry,
|
|
@@ -35,7 +35,6 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
|
|
|
35
35
|
});
|
|
36
36
|
describe('adapter selection by primitive type (defaults)', () => {
|
|
37
37
|
const cases = [
|
|
38
|
-
{ primitive: 'api', expectedAdapter: 'createApiLambdaHandler' },
|
|
39
38
|
{ primitive: 'subscriber', expectedAdapter: 'createSubscriberLambdaHandler' },
|
|
40
39
|
{ primitive: 'job', expectedAdapter: 'createJobLambdaHandler' },
|
|
41
40
|
{ primitive: 'webhook', expectedAdapter: 'createWebhookLambdaHandler' },
|
|
@@ -91,11 +90,11 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
|
|
|
91
90
|
expect(content).not.toContain(EXPOSED_ACTION_ADAPTER);
|
|
92
91
|
});
|
|
93
92
|
it('per-entry adapter override takes precedence over the primitive-type default', () => {
|
|
94
|
-
// Even
|
|
93
|
+
// Even when an explicit adapter is supplied, the override wins.
|
|
95
94
|
const overridden = entry('charge-card', EXPOSED_ACTION_ADAPTER);
|
|
96
|
-
const content = buildDedicatedEntryContent('./handler.js', overridden, '
|
|
95
|
+
const content = buildDedicatedEntryContent('./handler.js', overridden, 'webhook', DOMAIN_ID);
|
|
97
96
|
expect(content).toContain(`import { ${EXPOSED_ACTION_ADAPTER} } from '@mettlecast/domain-runtime';`);
|
|
98
|
-
expect(content).not.toContain('
|
|
97
|
+
expect(content).not.toContain('createWebhookLambdaHandler');
|
|
99
98
|
});
|
|
100
99
|
});
|
|
101
100
|
describe('schedule envelope', () => {
|
|
@@ -120,7 +119,7 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
|
|
|
120
119
|
describe('handler import path wiring', () => {
|
|
121
120
|
it('uses the caller-supplied relative import path verbatim', () => {
|
|
122
121
|
const e = entry('list-things');
|
|
123
|
-
const content = buildDedicatedEntryContent('../../../../src/handlers/list-things.ts', e, '
|
|
122
|
+
const content = buildDedicatedEntryContent('../../../../src/handlers/list-things.ts', e, 'webhook', DOMAIN_ID);
|
|
124
123
|
expect(content).toContain(`import { listThings } from '../../../../src/handlers/list-things.ts';`);
|
|
125
124
|
});
|
|
126
125
|
it('converts kebab-case handler IDs to camelCase export names', () => {
|
|
@@ -132,7 +131,7 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
|
|
|
132
131
|
});
|
|
133
132
|
it('emits a single export const handler per entry', () => {
|
|
134
133
|
const e = entry('list-things');
|
|
135
|
-
const content = buildDedicatedEntryContent('./handler.js', e, '
|
|
134
|
+
const content = buildDedicatedEntryContent('./handler.js', e, 'webhook', DOMAIN_ID);
|
|
136
135
|
const matches = content.match(/^export const handler\b/gm) ?? [];
|
|
137
136
|
expect(matches.length).toBe(1);
|
|
138
137
|
});
|
|
@@ -5,12 +5,12 @@ import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { LambdaFactory } from '../lambda-factory.js';
|
|
8
|
-
const DOMAIN_ROOT = '
|
|
8
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.tmp', 'test-domain-cdk-packer');
|
|
9
9
|
const minimalRegistry = {
|
|
10
10
|
schemaVersion: '1',
|
|
11
11
|
domainRoot: DOMAIN_ROOT,
|
|
12
12
|
domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
|
|
13
|
-
|
|
13
|
+
webhooks: [], subscribers: [], schedules: [], jobs: [], actions: [], integrations: [], events: [],
|
|
14
14
|
};
|
|
15
15
|
beforeAll(() => {
|
|
16
16
|
const handlerDir = path.join(DOMAIN_ROOT, 'src');
|
|
@@ -26,7 +26,7 @@ describe('LambdaFactory', () => {
|
|
|
26
26
|
const stack = new cdk.Stack(app, 'TestStack');
|
|
27
27
|
const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
|
|
28
28
|
expect(() => {
|
|
29
|
-
factory.createFunction({ id: 'test-
|
|
29
|
+
factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
|
|
30
30
|
}).not.toThrow();
|
|
31
31
|
});
|
|
32
32
|
describe('bundling configuration', () => {
|
|
@@ -34,7 +34,7 @@ describe('LambdaFactory', () => {
|
|
|
34
34
|
const app = new cdk.App();
|
|
35
35
|
const stack = new cdk.Stack(app, 'TestStackArch');
|
|
36
36
|
const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
|
|
37
|
-
factory.createFunction({ id: 'test-
|
|
37
|
+
factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
|
|
38
38
|
const template = Template.fromStack(stack);
|
|
39
39
|
template.hasResourceProperties('AWS::Lambda::Function', {
|
|
40
40
|
Architectures: ['arm64'],
|
|
@@ -44,7 +44,7 @@ describe('LambdaFactory', () => {
|
|
|
44
44
|
const app = new cdk.App();
|
|
45
45
|
const stack = new cdk.Stack(app, 'TestStackRuntime');
|
|
46
46
|
const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
|
|
47
|
-
factory.createFunction({ id: 'test-
|
|
47
|
+
factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
|
|
48
48
|
const template = Template.fromStack(stack);
|
|
49
49
|
template.hasResourceProperties('AWS::Lambda::Function', {
|
|
50
50
|
Runtime: 'nodejs22.x',
|
|
@@ -54,7 +54,7 @@ describe('LambdaFactory', () => {
|
|
|
54
54
|
const app = new cdk.App();
|
|
55
55
|
const stack = new cdk.Stack(app, 'TestStackSourceMaps');
|
|
56
56
|
const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
|
|
57
|
-
factory.createFunction({ id: 'test-
|
|
57
|
+
factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
|
|
58
58
|
const template = Template.fromStack(stack);
|
|
59
59
|
template.hasResourceProperties('AWS::Lambda::Function', {
|
|
60
60
|
Environment: {
|
|
@@ -68,7 +68,7 @@ describe('LambdaFactory', () => {
|
|
|
68
68
|
const app = new cdk.App();
|
|
69
69
|
const stack = new cdk.Stack(app, 'TestStackDefaults');
|
|
70
70
|
const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
|
|
71
|
-
const fn = factory.createFunction({ id: 'test-
|
|
71
|
+
const fn = factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
|
|
72
72
|
expect(fn).toBeInstanceOf(lambdaNode.NodejsFunction);
|
|
73
73
|
expect(fn.runtime?.name).toContain('nodejs22');
|
|
74
74
|
});
|
|
@@ -5,7 +5,6 @@ describe('DomainRegistry type', () => {
|
|
|
5
5
|
schemaVersion: '1',
|
|
6
6
|
domainRoot: '/workspace/my-domain',
|
|
7
7
|
domain: { id: 'my-domain', kind: 'domain', name: 'My Domain', tenancy: 'required' },
|
|
8
|
-
apis: [],
|
|
9
8
|
webhooks: [],
|
|
10
9
|
subscribers: [],
|
|
11
10
|
schedules: [],
|
|
@@ -19,12 +18,12 @@ describe('DomainRegistry type', () => {
|
|
|
19
18
|
});
|
|
20
19
|
it('discriminates RegistryEntry union by kind', () => {
|
|
21
20
|
const entries = [
|
|
22
|
-
{ id: 'test-
|
|
21
|
+
{ id: 'test-action', kind: 'action', handlerFile: 'src/action.ts', backendAccess: 'domain', exposure: { type: 'api', path: '/test', method: 'GET', auth: 'required', tenancy: 'none' }, idempotent: true },
|
|
23
22
|
{ id: 'test-job', kind: 'job', handlerFile: 'src/job.ts', maxRetries: 3, visibilityTimeoutSeconds: 30 },
|
|
24
23
|
];
|
|
25
24
|
for (const entry of entries) {
|
|
26
|
-
if (entry.kind === '
|
|
27
|
-
expect(entry.
|
|
25
|
+
if (entry.kind === 'action') {
|
|
26
|
+
expect(entry.exposure).toBeDefined();
|
|
28
27
|
}
|
|
29
28
|
if (entry.kind === 'job') {
|
|
30
29
|
expect(entry.maxRetries).toBeDefined();
|
|
@@ -8,7 +8,6 @@ const baseRegistry = {
|
|
|
8
8
|
schemaVersion: '1',
|
|
9
9
|
domainRoot: DOMAIN_ROOT,
|
|
10
10
|
domain: { id: 'sec-domain', kind: 'domain', name: 'Sec Domain', tenancy: 'none' },
|
|
11
|
-
apis: [],
|
|
12
11
|
webhooks: [],
|
|
13
12
|
subscribers: [],
|
|
14
13
|
schedules: [],
|
|
@@ -60,9 +59,15 @@ beforeAll(() => {
|
|
|
60
59
|
// entry id (`list-users` -> `listUsers`). We provide every named export
|
|
61
60
|
// the security-aspect test uses so each test can pick the same handler
|
|
62
61
|
// file regardless of which entry id it instantiates.
|
|
62
|
+
//
|
|
63
|
+
// Issue #4689: defineApi was removed; HTTP endpoints are now declared
|
|
64
|
+
// as actions with `exposure.type === 'api'`. Each named export below
|
|
65
|
+
// matches one of the action ids used in the test fixtures.
|
|
63
66
|
fs.writeFileSync(path.join(handlerDir, 'noop.ts'), [
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
// Match the grouped-lambda-factory's export-name conversion so the
|
|
68
|
+
// bundler can resolve the import for every test fixture.
|
|
69
|
+
'export const listUsers = { id: "list-users", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/users", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
|
|
70
|
+
'export const listAll = { id: "list-all", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/all", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
|
|
66
71
|
'export const noopAction = { id: "noop-action", backendAccess: "private", exposure: { type: "internal" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
|
|
67
72
|
].join('\n') + '\n');
|
|
68
73
|
});
|
|
@@ -75,7 +80,7 @@ afterAll(() => {
|
|
|
75
80
|
* invariants the CLI validator already enforces. These tests pin each
|
|
76
81
|
* invariant independently so a regression in any single check is caught.
|
|
77
82
|
*/
|
|
78
|
-
describe('SecurityAssertionAspect (#4662 Task D)', () => {
|
|
83
|
+
describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
|
|
79
84
|
it('does not emit errors for an empty registry', () => {
|
|
80
85
|
const { errorMessages } = buildStackAndGetErrors(baseRegistry);
|
|
81
86
|
// The base stack emits zero routes / zero Function URLs, so the aspect
|
|
@@ -83,21 +88,34 @@ describe('SecurityAssertionAspect (#4662 Task D)', () => {
|
|
|
83
88
|
const aspectCodes = ['SECURITY_MISSING_JWT_AUTHORIZER', 'SECURITY_MISSING_TENANT_PATH', 'SECURITY_ACTION_FUNCTION_URL', 'SECURITY_MISSING_SECURITY_EXCEPTION'];
|
|
84
89
|
expect(errorMessages.filter(m => aspectCodes.some(c => m.includes(c)))).toHaveLength(0);
|
|
85
90
|
});
|
|
86
|
-
it('emits SECURITY_MISSING_TENANT_PATH when an API path lacks the tenant placeholder', () => {
|
|
91
|
+
it('emits SECURITY_MISSING_TENANT_PATH when an action API path lacks the tenant placeholder', () => {
|
|
92
|
+
// Issue #4689: the canonical HTTP endpoint surface is the action
|
|
93
|
+
// array with `exposure.type === 'api'`. The aspect now reads from
|
|
94
|
+
// `actions[]` rather than `apis[]`.
|
|
87
95
|
const registry = {
|
|
88
96
|
...baseRegistry,
|
|
89
|
-
|
|
90
|
-
{
|
|
97
|
+
actions: [
|
|
98
|
+
{
|
|
99
|
+
id: 'list-users', kind: 'action', handlerFile: 'src/handlers/noop.ts',
|
|
100
|
+
backendAccess: 'domain',
|
|
101
|
+
exposure: { type: 'api', path: '/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
102
|
+
idempotent: false,
|
|
103
|
+
},
|
|
91
104
|
],
|
|
92
105
|
};
|
|
93
106
|
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
94
107
|
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH') && m.includes('list-users'))).toBe(true);
|
|
95
108
|
});
|
|
96
|
-
it('does NOT emit SECURITY_MISSING_TENANT_PATH when the path includes the placeholder', () => {
|
|
109
|
+
it('does NOT emit SECURITY_MISSING_TENANT_PATH when the action API path includes the placeholder', () => {
|
|
97
110
|
const registry = {
|
|
98
111
|
...baseRegistry,
|
|
99
|
-
|
|
100
|
-
{
|
|
112
|
+
actions: [
|
|
113
|
+
{
|
|
114
|
+
id: 'list-users', kind: 'action', handlerFile: 'src/handlers/noop.ts',
|
|
115
|
+
backendAccess: 'domain',
|
|
116
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
117
|
+
idempotent: false,
|
|
118
|
+
},
|
|
101
119
|
],
|
|
102
120
|
};
|
|
103
121
|
// We expect the JWT authorizer error (because no Cognito is provided),
|
|
@@ -105,11 +123,16 @@ describe('SecurityAssertionAspect (#4662 Task D)', () => {
|
|
|
105
123
|
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
106
124
|
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH'))).toBe(false);
|
|
107
125
|
});
|
|
108
|
-
it('emits SECURITY_MISSING_JWT_AUTHORIZER when
|
|
126
|
+
it('emits SECURITY_MISSING_JWT_AUTHORIZER when an action API is declared without Cognito config', () => {
|
|
109
127
|
const registry = {
|
|
110
128
|
...baseRegistry,
|
|
111
|
-
|
|
112
|
-
{
|
|
129
|
+
actions: [
|
|
130
|
+
{
|
|
131
|
+
id: 'list-users', kind: 'action', handlerFile: 'src/handlers/noop.ts',
|
|
132
|
+
backendAccess: 'domain',
|
|
133
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
134
|
+
idempotent: false,
|
|
135
|
+
},
|
|
113
136
|
],
|
|
114
137
|
};
|
|
115
138
|
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
@@ -121,7 +144,6 @@ describe('SecurityAssertionAspect (#4662 Task D)', () => {
|
|
|
121
144
|
// ignores them), the aspect must NOT fire for them.
|
|
122
145
|
const registry = {
|
|
123
146
|
...baseRegistry,
|
|
124
|
-
apis: [],
|
|
125
147
|
};
|
|
126
148
|
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
127
149
|
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
|
|
@@ -113,8 +113,7 @@ export declare class SecurityAssertionAspect implements cdk.IAspect {
|
|
|
113
113
|
* invariants in one pass:
|
|
114
114
|
*
|
|
115
115
|
* 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
|
|
116
|
-
* Covers
|
|
117
|
-
* new-style action API exposures (`exposure.auth === 'required'`).
|
|
116
|
+
* Covers action API exposures (`exposure.auth === 'required'`).
|
|
118
117
|
* 2. Routes declared as tenant-required include the canonical
|
|
119
118
|
* `/v1/tenants/{tenantId}/` placeholder in their path.
|
|
120
119
|
*
|
|
@@ -136,46 +135,17 @@ export declare class SecurityAssertionAspect implements cdk.IAspect {
|
|
|
136
135
|
*/
|
|
137
136
|
private assertNoFunctionUrls;
|
|
138
137
|
/**
|
|
139
|
-
* Walk every `CfnRoute` whose registry counterpart declares
|
|
140
|
-
* `auth: 'none'`
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* relaxation.
|
|
138
|
+
* Walk every `CfnRoute` whose action registry counterpart declares
|
|
139
|
+
* `auth: 'none'` and verify that the action exposure carries a
|
|
140
|
+
* `securityException.reason`. Public routes must document the exception
|
|
141
|
+
* so security reviewers can audit the relaxation.
|
|
144
142
|
*/
|
|
145
143
|
private assertAnonymousRoutesHaveException;
|
|
146
|
-
/**
|
|
147
|
-
* Find the registry API entry that corresponds to a synthesised route.
|
|
148
|
-
* DomainStack prefixes every route with `/<domainId>`, so we strip that
|
|
149
|
-
* prefix before comparing paths.
|
|
150
|
-
*
|
|
151
|
-
* @param path - The route path including the domain prefix.
|
|
152
|
-
* @param method - Upper-case HTTP method.
|
|
153
|
-
* @returns The matching {@link ApiRegistryEntry} or `undefined`.
|
|
154
|
-
*/
|
|
155
|
-
private findApiRegistryEntryForRoute;
|
|
156
144
|
/**
|
|
157
145
|
* Find the registry action whose `exposure.path` matches a synthesised
|
|
158
|
-
* route
|
|
159
|
-
* exposures.
|
|
146
|
+
* route for action API exposures.
|
|
160
147
|
*/
|
|
161
148
|
private findActionRegistryEntryForRoute;
|
|
162
|
-
/**
|
|
163
|
-
* Predicate — does the registry entry imply that the route must
|
|
164
|
-
* include `/v1/tenants/{tenantId}/`?
|
|
165
|
-
*
|
|
166
|
-
* The legacy `defineApi` surface does not carry an explicit tenancy
|
|
167
|
-
* marker; we treat any non-anonymous API as tenant-required because
|
|
168
|
-
* the only legitimate JWT-protected routes are tenant-scoped. Routes
|
|
169
|
-
* with `authType: 'none'` are exempted — they are explicitly public.
|
|
170
|
-
*/
|
|
171
|
-
private registryEntryRequiresTenant;
|
|
172
|
-
/**
|
|
173
|
-
* Predicate — does the API registry entry carry a securityException?
|
|
174
|
-
* Legacy `defineApi` rows do not have this field; we accept the
|
|
175
|
-
* registry absence and rely on the CLI validator to catch it before
|
|
176
|
-
* synth.
|
|
177
|
-
*/
|
|
178
|
-
private hasSecurityException;
|
|
179
149
|
/**
|
|
180
150
|
* Predicate — does the action registry entry carry a securityException
|
|
181
151
|
* on its exposure block?
|
|
@@ -89,8 +89,7 @@ export class SecurityAssertionAspect {
|
|
|
89
89
|
* invariants in one pass:
|
|
90
90
|
*
|
|
91
91
|
* 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
|
|
92
|
-
* Covers
|
|
93
|
-
* new-style action API exposures (`exposure.auth === 'required'`).
|
|
92
|
+
* Covers action API exposures (`exposure.auth === 'required'`).
|
|
94
93
|
* 2. Routes declared as tenant-required include the canonical
|
|
95
94
|
* `/v1/tenants/{tenantId}/` placeholder in their path.
|
|
96
95
|
*
|
|
@@ -113,27 +112,6 @@ export class SecurityAssertionAspect {
|
|
|
113
112
|
const path = routeKey.slice(spaceIndex + 1);
|
|
114
113
|
const method = routeKey.slice(0, spaceIndex).toUpperCase();
|
|
115
114
|
const authorizerId = node.authorizerId;
|
|
116
|
-
const api = this.findApiRegistryEntryForRoute(path, method);
|
|
117
|
-
if (api) {
|
|
118
|
-
// Invariant 1a: JWT-required API has an authorizer
|
|
119
|
-
if (api.authType === 'jwt' && !authorizerId) {
|
|
120
|
-
const message = `[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_JWT_AUTHORIZER}: ` +
|
|
121
|
-
`API '${api.id}' declares authType='jwt' but the synthesised route ${routeKey} ` +
|
|
122
|
-
`has no AuthorizerId. Provide a Cognito user pool via DomainStackProps.userPoolArn + userPoolClientId.`;
|
|
123
|
-
if (this.props.failOnMissingAuthorizer) {
|
|
124
|
-
cdk.Annotations.of(node).addError(message);
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
cdk.Annotations.of(node).addWarning(message);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// Invariant 2: tenant-required API path includes placeholder
|
|
131
|
-
if (this.registryEntryRequiresTenant(api) && !this.hasTenantPlaceholder(path)) {
|
|
132
|
-
cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_TENANT_PATH}: ` +
|
|
133
|
-
`API '${api.id}' (${method} ${path}) is tenant-required but its path does not include ` +
|
|
134
|
-
`the canonical '/v1/tenants/{tenantId}/' placeholder. Synth will fail.`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
115
|
const action = this.findActionRegistryEntryForRoute(path, method);
|
|
138
116
|
if (action && action.exposure.type === 'api') {
|
|
139
117
|
const exposure = action.exposure;
|
|
@@ -185,11 +163,10 @@ export class SecurityAssertionAspect {
|
|
|
185
163
|
}
|
|
186
164
|
}
|
|
187
165
|
/**
|
|
188
|
-
* Walk every `CfnRoute` whose registry counterpart declares
|
|
189
|
-
* `auth: 'none'`
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* relaxation.
|
|
166
|
+
* Walk every `CfnRoute` whose action registry counterpart declares
|
|
167
|
+
* `auth: 'none'` and verify that the action exposure carries a
|
|
168
|
+
* `securityException.reason`. Public routes must document the exception
|
|
169
|
+
* so security reviewers can audit the relaxation.
|
|
193
170
|
*/
|
|
194
171
|
assertAnonymousRoutesHaveException(node) {
|
|
195
172
|
if (!(node instanceof Apigwv2CfnRoute))
|
|
@@ -200,12 +177,6 @@ export class SecurityAssertionAspect {
|
|
|
200
177
|
return;
|
|
201
178
|
const path = routeKey.slice(spaceIndex + 1);
|
|
202
179
|
const method = routeKey.slice(0, spaceIndex).toUpperCase();
|
|
203
|
-
const api = this.findApiRegistryEntryForRoute(path, method);
|
|
204
|
-
if (api && api.authType === 'none' && !this.hasSecurityException(api)) {
|
|
205
|
-
cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_SECURITY_EXCEPTION}: ` +
|
|
206
|
-
`API '${api.id}' (${method} ${path}) is anonymous (authType='none') but does not declare ` +
|
|
207
|
-
`a securityException.reason. Public routes must document the exception.`);
|
|
208
|
-
}
|
|
209
180
|
const action = this.findActionRegistryEntryForRoute(path, method);
|
|
210
181
|
if (action
|
|
211
182
|
&& action.exposure.type === 'api'
|
|
@@ -216,26 +187,9 @@ export class SecurityAssertionAspect {
|
|
|
216
187
|
`exposure.securityException.reason. Public routes must document the exception.`);
|
|
217
188
|
}
|
|
218
189
|
}
|
|
219
|
-
/**
|
|
220
|
-
* Find the registry API entry that corresponds to a synthesised route.
|
|
221
|
-
* DomainStack prefixes every route with `/<domainId>`, so we strip that
|
|
222
|
-
* prefix before comparing paths.
|
|
223
|
-
*
|
|
224
|
-
* @param path - The route path including the domain prefix.
|
|
225
|
-
* @param method - Upper-case HTTP method.
|
|
226
|
-
* @returns The matching {@link ApiRegistryEntry} or `undefined`.
|
|
227
|
-
*/
|
|
228
|
-
findApiRegistryEntryForRoute(path, method) {
|
|
229
|
-
const domainId = this.props.registry.domain.id;
|
|
230
|
-
if (!path.startsWith(`/${domainId}`))
|
|
231
|
-
return undefined;
|
|
232
|
-
const trimmed = path.slice(`/${domainId}`.length) || '/';
|
|
233
|
-
return this.props.registry.apis.find(api => api.path === trimmed && api.method.toUpperCase() === method);
|
|
234
|
-
}
|
|
235
190
|
/**
|
|
236
191
|
* Find the registry action whose `exposure.path` matches a synthesised
|
|
237
|
-
* route
|
|
238
|
-
* exposures.
|
|
192
|
+
* route for action API exposures.
|
|
239
193
|
*/
|
|
240
194
|
findActionRegistryEntryForRoute(path, method) {
|
|
241
195
|
const domainId = this.props.registry.domain.id;
|
|
@@ -249,31 +203,6 @@ export class SecurityAssertionAspect {
|
|
|
249
203
|
return exposure.path === trimmed && exposure.method.toUpperCase() === method;
|
|
250
204
|
});
|
|
251
205
|
}
|
|
252
|
-
/**
|
|
253
|
-
* Predicate — does the registry entry imply that the route must
|
|
254
|
-
* include `/v1/tenants/{tenantId}/`?
|
|
255
|
-
*
|
|
256
|
-
* The legacy `defineApi` surface does not carry an explicit tenancy
|
|
257
|
-
* marker; we treat any non-anonymous API as tenant-required because
|
|
258
|
-
* the only legitimate JWT-protected routes are tenant-scoped. Routes
|
|
259
|
-
* with `authType: 'none'` are exempted — they are explicitly public.
|
|
260
|
-
*/
|
|
261
|
-
registryEntryRequiresTenant(api) {
|
|
262
|
-
if (api.authType === 'none')
|
|
263
|
-
return false;
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Predicate — does the API registry entry carry a securityException?
|
|
268
|
-
* Legacy `defineApi` rows do not have this field; we accept the
|
|
269
|
-
* registry absence and rely on the CLI validator to catch it before
|
|
270
|
-
* synth.
|
|
271
|
-
*/
|
|
272
|
-
hasSecurityException(api) {
|
|
273
|
-
const entry = api;
|
|
274
|
-
const reason = entry.securityException?.reason;
|
|
275
|
-
return typeof reason === 'string' && reason.trim().length > 0;
|
|
276
|
-
}
|
|
277
206
|
/**
|
|
278
207
|
* Predicate — does the action registry entry carry a securityException
|
|
279
208
|
* on its exposure block?
|
|
@@ -7,7 +7,7 @@ import type { DomainRegistry } from '../registry.js';
|
|
|
7
7
|
* Configuration properties for ApiConstruct.
|
|
8
8
|
*/
|
|
9
9
|
export interface ApiConstructProps {
|
|
10
|
-
/** Domain registry
|
|
10
|
+
/** Domain registry. Issue #4689: HTTP endpoints are now action entries with `exposure.type === 'api'`. */
|
|
11
11
|
registry: DomainRegistry;
|
|
12
12
|
/** Shared HttpApi to route requests to Lambdas. */
|
|
13
13
|
httpApi: apigwv2.HttpApi;
|
|
@@ -17,7 +17,15 @@ export interface ApiConstructProps {
|
|
|
17
17
|
iamBuilder: IamPolicyBuilder;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* CDK Construct that synthesises one Lambda per
|
|
20
|
+
* CDK Construct that synthesises one Lambda per API-exposed action and wires each to the shared HttpApi.
|
|
21
|
+
*
|
|
22
|
+
* Issue #4689: the legacy `defineApi` primitive was removed. The
|
|
23
|
+
* canonical HTTP endpoint surface is `defineAction({ exposure: { type:
|
|
24
|
+
* 'api', ... } })`, so this construct now iterates
|
|
25
|
+
* `registry.actions` and filters for `exposure.type === 'api'`. The
|
|
26
|
+
* legacy `registry.apis` slot is always empty in fresh registries; we
|
|
27
|
+
* intentionally do NOT iterate it because the builder no longer
|
|
28
|
+
* populates it.
|
|
21
29
|
*/
|
|
22
30
|
export declare class ApiConstruct extends Construct {
|
|
23
31
|
/**
|
|
@@ -2,7 +2,15 @@ import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
|
2
2
|
import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
3
3
|
import { Construct } from 'constructs';
|
|
4
4
|
/**
|
|
5
|
-
* CDK Construct that synthesises one Lambda per
|
|
5
|
+
* CDK Construct that synthesises one Lambda per API-exposed action and wires each to the shared HttpApi.
|
|
6
|
+
*
|
|
7
|
+
* Issue #4689: the legacy `defineApi` primitive was removed. The
|
|
8
|
+
* canonical HTTP endpoint surface is `defineAction({ exposure: { type:
|
|
9
|
+
* 'api', ... } })`, so this construct now iterates
|
|
10
|
+
* `registry.actions` and filters for `exposure.type === 'api'`. The
|
|
11
|
+
* legacy `registry.apis` slot is always empty in fresh registries; we
|
|
12
|
+
* intentionally do NOT iterate it because the builder no longer
|
|
13
|
+
* populates it.
|
|
6
14
|
*/
|
|
7
15
|
export class ApiConstruct extends Construct {
|
|
8
16
|
/**
|
|
@@ -13,15 +21,27 @@ export class ApiConstruct extends Construct {
|
|
|
13
21
|
*/
|
|
14
22
|
constructor(scope, id, props) {
|
|
15
23
|
super(scope, id);
|
|
16
|
-
for (const
|
|
24
|
+
for (const action of props.registry.actions) {
|
|
25
|
+
if (action.exposure?.type !== 'api')
|
|
26
|
+
continue;
|
|
27
|
+
const exposure = action.exposure;
|
|
28
|
+
const entry = {
|
|
29
|
+
...action,
|
|
30
|
+
// The action entry is structurally compatible with the lambda
|
|
31
|
+
// factory's expected shape; spread the exposure fields onto the
|
|
32
|
+
// top level so existing IAM / deployment logic does not need to
|
|
33
|
+
// change.
|
|
34
|
+
path: exposure.path,
|
|
35
|
+
method: exposure.method,
|
|
36
|
+
};
|
|
17
37
|
const fn = props.lambdaFactory.createFunction(entry, entry.deployment);
|
|
18
38
|
props.iamBuilder.forApi().forEach((statement) => {
|
|
19
39
|
fn.addToRolePolicy(statement);
|
|
20
40
|
});
|
|
21
41
|
props.httpApi.addRoutes({
|
|
22
|
-
path:
|
|
23
|
-
methods: [toHttpMethod(
|
|
24
|
-
integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(
|
|
42
|
+
path: exposure.path,
|
|
43
|
+
methods: [toHttpMethod(exposure.method)],
|
|
44
|
+
integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(action.id)}Integration`, fn),
|
|
25
45
|
});
|
|
26
46
|
}
|
|
27
47
|
}
|
|
@@ -2,7 +2,7 @@ import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
|
2
2
|
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
3
3
|
import { Construct } from 'constructs';
|
|
4
4
|
/** Primitive types that can be grouped into a single Lambda. */
|
|
5
|
-
export type PrimitiveType = '
|
|
5
|
+
export type PrimitiveType = 'subscriber' | 'schedule' | 'job' | 'webhook' | 'action';
|
|
6
6
|
/** A single handler entry within a grouped Lambda. */
|
|
7
7
|
export interface HandlerEntry {
|
|
8
8
|
/** Unique handler ID within the domain + primitive type. */
|
|
@@ -15,7 +15,6 @@ export interface HandlerEntry {
|
|
|
15
15
|
* into a Lambda handler.
|
|
16
16
|
*
|
|
17
17
|
* When omitted, the adapter is inferred from `primitiveType`:
|
|
18
|
-
* - `api` → `createApiLambdaHandler`
|
|
19
18
|
* - `subscriber` → `createSubscriberLambdaHandler`
|
|
20
19
|
* - `job` → `createJobLambdaHandler`
|
|
21
20
|
* - `webhook` → `createWebhookLambdaHandler`
|
|
@@ -20,7 +20,6 @@ function toPascalCase(str) {
|
|
|
20
20
|
* used by API-exposed actions (see `EXPOSED_ACTION_ADAPTER`).
|
|
21
21
|
*/
|
|
22
22
|
export const ADAPTER_BY_PRIMITIVE = {
|
|
23
|
-
api: 'createApiLambdaHandler',
|
|
24
23
|
subscriber: 'createSubscriberLambdaHandler',
|
|
25
24
|
job: 'createJobLambdaHandler',
|
|
26
25
|
webhook: 'createWebhookLambdaHandler',
|
|
@@ -83,7 +82,7 @@ export function buildDedicatedEntryContent(handlerImportPath, entry, primitiveTy
|
|
|
83
82
|
// primitive-type default. Used so API-exposed actions wrap with
|
|
84
83
|
// `createExposedActionApiHandler` while internal actions in the same domain
|
|
85
84
|
// continue to use `createActionLambdaHandler`.
|
|
86
|
-
const adapter = entry.adapter ?? ADAPTER_BY_PRIMITIVE[primitiveType]
|
|
85
|
+
const adapter = entry.adapter ?? ADAPTER_BY_PRIMITIVE[primitiveType];
|
|
87
86
|
// Wave 7 Task 7.1 (#4619): the action adapters take a (registry, options)
|
|
88
87
|
// pair / (action, options) pair, NOT a bare handler export. Generating
|
|
89
88
|
// `${adapter}(${exportName})` would produce invalid JS for actions — the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig,
|
|
1
|
+
export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, ActionRegistryEntry, ActionBackendAccess, ActionExposure, ActionApiExposure, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SchemaSnapshot, } from './registry.js';
|
|
2
2
|
export { LambdaFactory } from './lambda-factory.js';
|
|
3
3
|
export type { LambdaFactoryProps } from './lambda-factory.js';
|
|
4
4
|
export { createGroupedLambdas, buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER } from './grouped-lambda-factory.js';
|
package/dist/pack-domain.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface PackDomainOptions {
|
|
|
47
47
|
projectId?: string;
|
|
48
48
|
/** Environment code used to prefix per-domain physical resource names (e.g. `dev`, `prod`). */
|
|
49
49
|
envCode?: string;
|
|
50
|
+
/** Disable auto-generated per-domain CloudWatch dashboard. */
|
|
51
|
+
disableCloudWatchDashboards?: boolean;
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
54
|
* Convenience entry-point: constructs a DomainStack from a compiled registry.
|
package/dist/pack-domain.js
CHANGED
|
@@ -13,9 +13,14 @@ import { DomainStack } from './DomainStack.js';
|
|
|
13
13
|
export function packDomain(registry, app, stackId, eventBusArn, options) {
|
|
14
14
|
// Support both old (env as 5th arg) and new (options object) calling conventions
|
|
15
15
|
// Check if options looks like a CDK Environment (has 'account' and/or 'region' props, not the full options interface)
|
|
16
|
-
const opts = options &&
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const opts = options &&
|
|
17
|
+
typeof options === 'object' &&
|
|
18
|
+
('account' in options || 'region' in options) &&
|
|
19
|
+
!('userPoolArn' in options ||
|
|
20
|
+
'userPoolId' in options ||
|
|
21
|
+
'userPoolClientId' in options ||
|
|
22
|
+
'databaseUrl' in options ||
|
|
23
|
+
'dbSecretArn' in options)
|
|
19
24
|
? { env: options }
|
|
20
25
|
: options || {};
|
|
21
26
|
return new DomainStack(app, stackId, {
|
|
@@ -41,5 +46,6 @@ export function packDomain(registry, app, stackId, eventBusArn, options) {
|
|
|
41
46
|
eventBusName: opts.eventBusName,
|
|
42
47
|
projectId: opts.projectId,
|
|
43
48
|
envCode: opts.envCode,
|
|
49
|
+
disableCloudWatchDashboards: opts.disableCloudWatchDashboards,
|
|
44
50
|
});
|
|
45
51
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type SchemaSnapshot = Record<string, unknown>;
|
|
|
5
5
|
/**
|
|
6
6
|
* Kind discriminant for registry entries.
|
|
7
7
|
*/
|
|
8
|
-
export type RegistryEntryKind = '
|
|
8
|
+
export type RegistryEntryKind = 'webhook' | 'subscriber' | 'schedule' | 'job' | 'action' | 'integration' | 'event' | 'domain';
|
|
9
9
|
/** Controls whether a VPC-attached handler may use NAT-backed public internet egress. */
|
|
10
10
|
export type RegistryOutboundAccess = 'internal' | 'internet';
|
|
11
11
|
/**
|
|
@@ -38,61 +38,6 @@ export interface BaseRegistryEntry {
|
|
|
38
38
|
/** Optional human-readable description of this primitive. */
|
|
39
39
|
description?: string;
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
42
|
-
* Schema snapshot for a single API version.
|
|
43
|
-
*/
|
|
44
|
-
export interface ApiVersionSnapshot {
|
|
45
|
-
/** Semver-style version string, e.g. '1'. */
|
|
46
|
-
version: string;
|
|
47
|
-
/** JSON Schema for the request body of this version. */
|
|
48
|
-
requestSchema?: SchemaSnapshot;
|
|
49
|
-
/** JSON Schema for the successful response body of this version. */
|
|
50
|
-
responseSchema?: SchemaSnapshot;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* API registry entry for HTTP endpoint handlers.
|
|
54
|
-
*/
|
|
55
|
-
export interface ApiRegistryEntry extends BaseRegistryEntry {
|
|
56
|
-
/** Discriminant. */
|
|
57
|
-
kind: 'api';
|
|
58
|
-
/** Handler file path (required for API). */
|
|
59
|
-
handlerFile: string;
|
|
60
|
-
/** HTTP route path, e.g. '/users/:id'. */
|
|
61
|
-
path: string;
|
|
62
|
-
/** HTTP method. */
|
|
63
|
-
method: string;
|
|
64
|
-
/** Authentication type. */
|
|
65
|
-
authType: 'jwt' | 'api-key' | 'none';
|
|
66
|
-
/**
|
|
67
|
-
* Required for `authType: 'none'` (anonymous) APIs. Carries a free-text
|
|
68
|
-
* reason that documents the security exception so reviewers can audit
|
|
69
|
-
* the relaxation. The CDK synth-time aspect and the CLI validator both
|
|
70
|
-
* refuse to synthesize anonymous APIs without this field.
|
|
71
|
-
*
|
|
72
|
-
* Added in #4662 Task D — the legacy `defineApi` surface did not
|
|
73
|
-
* require a reason because early projects had only a handful of
|
|
74
|
-
* public routes; that did not scale and the runtime needs the
|
|
75
|
-
* documentation alongside the route.
|
|
76
|
-
*/
|
|
77
|
-
securityException?: {
|
|
78
|
-
reason: string;
|
|
79
|
-
};
|
|
80
|
-
/** Optional deployment overrides. */
|
|
81
|
-
deployment?: SerialDeploymentConfig;
|
|
82
|
-
/** Whether this handler may use NAT-backed public internet egress. */
|
|
83
|
-
outboundAccess?: RegistryOutboundAccess;
|
|
84
|
-
/** JSON Schema snapshot for the HTTP request body. */
|
|
85
|
-
requestSchema?: SchemaSnapshot;
|
|
86
|
-
/** JSON Schema snapshot for the HTTP response body. */
|
|
87
|
-
responseSchema?: SchemaSnapshot;
|
|
88
|
-
/** Per-version request/response schema snapshots. */
|
|
89
|
-
versions?: ApiVersionSnapshot[];
|
|
90
|
-
/** Example request/response payloads for documentation (no runtime impact). */
|
|
91
|
-
examples?: {
|
|
92
|
-
request?: Record<string, unknown>;
|
|
93
|
-
response?: Record<string, unknown>;
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
41
|
/**
|
|
97
42
|
* Webhook registry entry for webhook handlers.
|
|
98
43
|
*/
|
|
@@ -321,7 +266,7 @@ export interface DomainRegistryEntry extends BaseRegistryEntry {
|
|
|
321
266
|
/**
|
|
322
267
|
* Union type of all registry entry kinds.
|
|
323
268
|
*/
|
|
324
|
-
export type RegistryEntry =
|
|
269
|
+
export type RegistryEntry = WebhookRegistryEntry | SubscriberRegistryEntry | ScheduleRegistryEntry | JobRegistryEntry | ActionRegistryEntry | IntegrationRegistryEntry | EventRegistryEntry | DomainRegistryEntry;
|
|
325
270
|
/**
|
|
326
271
|
* Top-level domain registry — the compiled snapshot for CDK packer.
|
|
327
272
|
*/
|
|
@@ -332,8 +277,6 @@ export interface DomainRegistry {
|
|
|
332
277
|
domainRoot: string;
|
|
333
278
|
/** The domain itself. */
|
|
334
279
|
domain: DomainRegistryEntry;
|
|
335
|
-
/** All API endpoints. */
|
|
336
|
-
apis: ApiRegistryEntry[];
|
|
337
280
|
/** All webhook receivers. */
|
|
338
281
|
webhooks: WebhookRegistryEntry[];
|
|
339
282
|
/** All event subscribers. */
|