@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.
- package/dist/DomainStack.d.ts +5 -0
- package/dist/DomainStack.js +145 -67
- package/dist/__tests__/action-construct.test.d.ts +1 -0
- package/dist/__tests__/action-construct.test.js +159 -0
- package/dist/__tests__/domain-stack.test.js +315 -15
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
- package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
- package/dist/__tests__/grouped-lambda-factory.test.js +146 -0
- package/dist/__tests__/lambda-factory.test.js +7 -7
- package/dist/__tests__/registry.test.js +111 -4
- package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
- package/dist/__tests__/security-assertion-aspect.test.js +222 -0
- package/dist/aspects/index.d.ts +4 -0
- package/dist/aspects/index.js +2 -0
- package/dist/aspects/security-assertion-aspect.d.ts +161 -0
- package/dist/aspects/security-assertion-aspect.js +226 -0
- package/dist/constructs/action-construct.d.ts +16 -6
- package/dist/constructs/action-construct.js +10 -11
- package/dist/constructs/api-construct.d.ts +10 -2
- package/dist/constructs/api-construct.js +25 -5
- package/dist/grouped-lambda-factory.d.ts +69 -1
- package/dist/grouped-lambda-factory.js +98 -10
- package/dist/iam/iam-policy-builder.js +0 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/pack-domain.d.ts +2 -0
- package/dist/pack-domain.js +9 -3
- package/dist/registry.d.ts +98 -47
- package/package.json +1 -1
|
@@ -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();
|
|
@@ -32,3 +31,111 @@ describe('DomainRegistry type', () => {
|
|
|
32
31
|
}
|
|
33
32
|
});
|
|
34
33
|
});
|
|
34
|
+
/**
|
|
35
|
+
* Action-first migration (#4619, Wave 1 Task 1.2):
|
|
36
|
+
*
|
|
37
|
+
* ActionRegistryEntry must surface `backendAccess` and `exposure` so the
|
|
38
|
+
* registry builder can carry them through to CDK/contract generation.
|
|
39
|
+
* The legacy `visibility` field stays optional during the migration window.
|
|
40
|
+
*/
|
|
41
|
+
describe('ActionRegistryEntry type (action-first migration)', () => {
|
|
42
|
+
it('requires backendAccess and exposure on every action entry', () => {
|
|
43
|
+
const entry = {
|
|
44
|
+
id: 'charge-card',
|
|
45
|
+
kind: 'action',
|
|
46
|
+
handlerFile: 'src/actions/charge-card.ts',
|
|
47
|
+
backendAccess: 'domain',
|
|
48
|
+
exposure: { type: 'internal' },
|
|
49
|
+
idempotent: true,
|
|
50
|
+
};
|
|
51
|
+
expect(entry.backendAccess).toBe('domain');
|
|
52
|
+
expect(entry.exposure).toEqual({ type: 'internal' });
|
|
53
|
+
});
|
|
54
|
+
it('accepts the full backendAccess scope union', () => {
|
|
55
|
+
const scopes = ['private', 'domain', 'platform'];
|
|
56
|
+
for (const backendAccess of scopes) {
|
|
57
|
+
const entry = {
|
|
58
|
+
id: `a-${backendAccess}`,
|
|
59
|
+
kind: 'action',
|
|
60
|
+
handlerFile: 'a.ts',
|
|
61
|
+
backendAccess,
|
|
62
|
+
exposure: { type: 'internal' },
|
|
63
|
+
idempotent: false,
|
|
64
|
+
};
|
|
65
|
+
expect(entry.backendAccess).toBe(backendAccess);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it('accepts an api exposure with all optional metadata', () => {
|
|
69
|
+
const exposure = {
|
|
70
|
+
type: 'api',
|
|
71
|
+
path: '/v1/tenants/{tenantId}/billing/invoices',
|
|
72
|
+
method: 'POST',
|
|
73
|
+
auth: 'required',
|
|
74
|
+
tenancy: 'required',
|
|
75
|
+
roles: ['billing-admin'],
|
|
76
|
+
securityException: { reason: 'bootstrap path before tenant exists' },
|
|
77
|
+
};
|
|
78
|
+
const entry = {
|
|
79
|
+
id: 'create-invoice',
|
|
80
|
+
kind: 'action',
|
|
81
|
+
handlerFile: 'src/actions/create-invoice.ts',
|
|
82
|
+
backendAccess: 'domain',
|
|
83
|
+
exposure,
|
|
84
|
+
idempotent: true,
|
|
85
|
+
};
|
|
86
|
+
expect(entry.exposure.type).toBe('api');
|
|
87
|
+
if (entry.exposure.type === 'api') {
|
|
88
|
+
expect(entry.exposure.path).toContain('{tenantId}');
|
|
89
|
+
expect(entry.exposure.method).toBe('POST');
|
|
90
|
+
expect(entry.exposure.auth).toBe('required');
|
|
91
|
+
expect(entry.exposure.tenancy).toBe('required');
|
|
92
|
+
expect(entry.exposure.roles).toEqual(['billing-admin']);
|
|
93
|
+
expect(entry.exposure.securityException?.reason).toMatch(/bootstrap/);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
it('discriminates the ActionExposure union by type', () => {
|
|
97
|
+
const samples = [
|
|
98
|
+
{ type: 'internal' },
|
|
99
|
+
{ type: 'api', path: '/v1/x', method: 'GET', auth: 'none', tenancy: 'none' },
|
|
100
|
+
];
|
|
101
|
+
for (const exposure of samples) {
|
|
102
|
+
if (exposure.type === 'api') {
|
|
103
|
+
expect(exposure.path).toBeDefined();
|
|
104
|
+
expect(exposure.method).toBe('GET');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
expect(exposure.type).toBe('internal');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
it('keeps legacy visibility as an optional field during the migration', () => {
|
|
112
|
+
// The legacy field must remain present-but-optional on the type so
|
|
113
|
+
// existing CDK constructs (e.g. action-construct.ts) keep compiling
|
|
114
|
+
// until they are updated to consume backendAccess directly.
|
|
115
|
+
const entry = {
|
|
116
|
+
id: 'legacy',
|
|
117
|
+
kind: 'action',
|
|
118
|
+
handlerFile: 'src/actions/legacy.ts',
|
|
119
|
+
backendAccess: 'private',
|
|
120
|
+
exposure: { type: 'internal' },
|
|
121
|
+
visibility: 'workspace',
|
|
122
|
+
idempotent: false,
|
|
123
|
+
};
|
|
124
|
+
expect(entry.visibility).toBe('workspace');
|
|
125
|
+
expect(entry.backendAccess).toBe('private');
|
|
126
|
+
});
|
|
127
|
+
it('preserves input/output schema snapshot fields', () => {
|
|
128
|
+
const entry = {
|
|
129
|
+
id: 'with-schemas',
|
|
130
|
+
kind: 'action',
|
|
131
|
+
handlerFile: 'src/actions/with-schemas.ts',
|
|
132
|
+
backendAccess: 'domain',
|
|
133
|
+
exposure: { type: 'internal' },
|
|
134
|
+
idempotent: false,
|
|
135
|
+
inputSchema: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
136
|
+
outputSchema: { type: 'object', properties: { id: { type: 'string' } } },
|
|
137
|
+
};
|
|
138
|
+
expect(entry.inputSchema).toBeDefined();
|
|
139
|
+
expect(entry.outputSchema).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import * as cdk from 'aws-cdk-lib';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DomainStack } from '../DomainStack.js';
|
|
6
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-security-aspect');
|
|
7
|
+
const baseRegistry = {
|
|
8
|
+
schemaVersion: '1',
|
|
9
|
+
domainRoot: DOMAIN_ROOT,
|
|
10
|
+
domain: { id: 'sec-domain', kind: 'domain', name: 'Sec Domain', tenancy: 'none' },
|
|
11
|
+
webhooks: [],
|
|
12
|
+
subscribers: [],
|
|
13
|
+
schedules: [],
|
|
14
|
+
jobs: [],
|
|
15
|
+
actions: [],
|
|
16
|
+
integrations: [],
|
|
17
|
+
events: [],
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Build a stack, force synth, and return its error annotations so each
|
|
21
|
+
* test can assert specifically on the codes emitted by
|
|
22
|
+
* `SecurityAssertionAspect`. CDK aspects only run during synth, so the
|
|
23
|
+
* helper must call `app.synth()`. Annotations attached by aspects to
|
|
24
|
+
* child constructs (e.g. a `CfnRoute`) live on the child's metadata,
|
|
25
|
+
* not the stack's — we walk the construct tree to aggregate them.
|
|
26
|
+
*/
|
|
27
|
+
function buildStackAndGetErrors(registry) {
|
|
28
|
+
const app = new cdk.App();
|
|
29
|
+
const stack = new DomainStack(app, 'SecAspectStack', {
|
|
30
|
+
registry,
|
|
31
|
+
eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus',
|
|
32
|
+
});
|
|
33
|
+
// Force synthesis so the aspect's visit() fires.
|
|
34
|
+
try {
|
|
35
|
+
app.synth();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Synth may throw when the aspect emits a hard error; we still want
|
|
39
|
+
// to read the metadata so the assertion can find the error code.
|
|
40
|
+
}
|
|
41
|
+
const errorMessages = [];
|
|
42
|
+
const collect = (construct) => {
|
|
43
|
+
for (const m of construct.node.metadata) {
|
|
44
|
+
if (m.type === 'aws:cdk:error') {
|
|
45
|
+
errorMessages.push(typeof m.data === 'string' ? m.data : JSON.stringify(m.data));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const child of construct.node.children) {
|
|
49
|
+
collect(child);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
collect(stack);
|
|
53
|
+
return { stack, errorMessages };
|
|
54
|
+
}
|
|
55
|
+
beforeAll(() => {
|
|
56
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
57
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
58
|
+
// The CDK grouped-lambda-factory derives the named export from the
|
|
59
|
+
// entry id (`list-users` -> `listUsers`). We provide every named export
|
|
60
|
+
// the security-aspect test uses so each test can pick the same handler
|
|
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.
|
|
66
|
+
fs.writeFileSync(path.join(handlerDir, 'noop.ts'), [
|
|
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 }) };',
|
|
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 }) };',
|
|
72
|
+
].join('\n') + '\n');
|
|
73
|
+
});
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
/**
|
|
78
|
+
* Issue #4662 Task D — deployment-time assertions. The CDK aspect is the
|
|
79
|
+
* last line of defence: it walks the synthesised tree and verifies the
|
|
80
|
+
* invariants the CLI validator already enforces. These tests pin each
|
|
81
|
+
* invariant independently so a regression in any single check is caught.
|
|
82
|
+
*/
|
|
83
|
+
describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
|
|
84
|
+
it('does not emit errors for an empty registry', () => {
|
|
85
|
+
const { errorMessages } = buildStackAndGetErrors(baseRegistry);
|
|
86
|
+
// The base stack emits zero routes / zero Function URLs, so the aspect
|
|
87
|
+
// should have nothing to complain about.
|
|
88
|
+
const aspectCodes = ['SECURITY_MISSING_JWT_AUTHORIZER', 'SECURITY_MISSING_TENANT_PATH', 'SECURITY_ACTION_FUNCTION_URL', 'SECURITY_MISSING_SECURITY_EXCEPTION'];
|
|
89
|
+
expect(errorMessages.filter(m => aspectCodes.some(c => m.includes(c)))).toHaveLength(0);
|
|
90
|
+
});
|
|
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[]`.
|
|
95
|
+
const registry = {
|
|
96
|
+
...baseRegistry,
|
|
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
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
107
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH') && m.includes('list-users'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('does NOT emit SECURITY_MISSING_TENANT_PATH when the action API path includes the placeholder', () => {
|
|
110
|
+
const registry = {
|
|
111
|
+
...baseRegistry,
|
|
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
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
// We expect the JWT authorizer error (because no Cognito is provided),
|
|
122
|
+
// but NOT the tenant path error.
|
|
123
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
124
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH'))).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
it('emits SECURITY_MISSING_JWT_AUTHORIZER when an action API is declared without Cognito config', () => {
|
|
127
|
+
const registry = {
|
|
128
|
+
...baseRegistry,
|
|
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
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
139
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_JWT_AUTHORIZER') || m.includes('JWT-protected routes'))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it('does NOT emit SECURITY_MISSING_SECURITY_EXCEPTION when authType is none but the route is the health probe', () => {
|
|
142
|
+
// The DomainStack installs the health/ready probes as `authType: 'none'` routes.
|
|
143
|
+
// Because they are special-cased in the aspect (and CLI validator
|
|
144
|
+
// ignores them), the aspect must NOT fire for them.
|
|
145
|
+
const registry = {
|
|
146
|
+
...baseRegistry,
|
|
147
|
+
};
|
|
148
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
149
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
it('does NOT emit Function URL error for the existing health/ready probes', () => {
|
|
152
|
+
// HealthConstruct attaches `Lambda::Url` to its functions. The aspect
|
|
153
|
+
// exempts them via fn-name regex; we must not regress that exemption.
|
|
154
|
+
const registry = { ...baseRegistry };
|
|
155
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
156
|
+
// HealthConstruct uses NodejsFunction which DOES NOT create Lambda::Url
|
|
157
|
+
// (URLs are only created when fn.addFunctionUrl is called). So there
|
|
158
|
+
// should be no Function URL errors at all on the base stack.
|
|
159
|
+
expect(errorMessages.some(m => m.includes('SECURITY_ACTION_FUNCTION_URL'))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
it('emits SECURITY_MISSING_TENANT_PATH for an action exposure with tenancy=required but bad path', () => {
|
|
162
|
+
const registry = {
|
|
163
|
+
...baseRegistry,
|
|
164
|
+
actions: [
|
|
165
|
+
{
|
|
166
|
+
id: 'list-all',
|
|
167
|
+
kind: 'action',
|
|
168
|
+
handlerFile: 'src/handlers/noop.ts',
|
|
169
|
+
backendAccess: 'domain',
|
|
170
|
+
exposure: { type: 'api', path: '/v1/admin/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
171
|
+
idempotent: false,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
176
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH') && m.includes('list-all'))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it('emits SECURITY_MISSING_JWT_AUTHORIZER for an action exposure with auth=required but no Cognito', () => {
|
|
179
|
+
const registry = {
|
|
180
|
+
...baseRegistry,
|
|
181
|
+
actions: [
|
|
182
|
+
{
|
|
183
|
+
id: 'list-users',
|
|
184
|
+
kind: 'action',
|
|
185
|
+
handlerFile: 'src/handlers/noop.ts',
|
|
186
|
+
backendAccess: 'domain',
|
|
187
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
188
|
+
idempotent: false,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
193
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_JWT_AUTHORIZER') && m.includes('list-users'))).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
it('does NOT emit SECURITY_MISSING_SECURITY_EXCEPTION for an action with auth=none AND securityException.reason', () => {
|
|
196
|
+
const registry = {
|
|
197
|
+
...baseRegistry,
|
|
198
|
+
actions: [
|
|
199
|
+
{
|
|
200
|
+
id: 'noop-action',
|
|
201
|
+
kind: 'action',
|
|
202
|
+
handlerFile: 'src/handlers/noop.ts',
|
|
203
|
+
backendAccess: 'platform',
|
|
204
|
+
exposure: {
|
|
205
|
+
type: 'api',
|
|
206
|
+
path: '/v1/health',
|
|
207
|
+
method: 'GET',
|
|
208
|
+
auth: 'none',
|
|
209
|
+
tenancy: 'none',
|
|
210
|
+
securityException: { reason: 'public liveness probe; ticket OPS-123' },
|
|
211
|
+
},
|
|
212
|
+
idempotent: false,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
217
|
+
// Note: the CLI validator's AUTH_NONE_REQUIRES_EXCEPTION rule mirrors
|
|
218
|
+
// this; we only check that the *aspect* does not block a well-formed
|
|
219
|
+
// action.
|
|
220
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
package/dist/aspects/index.d.ts
CHANGED
|
@@ -3,3 +3,7 @@ export type { TibTaggingAspectProps } from './tagging-aspect.js';
|
|
|
3
3
|
export { LogRetentionAspect } from './log-retention-aspect.js';
|
|
4
4
|
export { IamBoundariesAspect } from './iam-boundaries-aspect.js';
|
|
5
5
|
export type { IamBoundariesAspectProps } from './iam-boundaries-aspect.js';
|
|
6
|
+
export { SecurityAssertionAspect } from './security-assertion-aspect.js';
|
|
7
|
+
export type { SecurityAssertionAspectProps } from './security-assertion-aspect.js';
|
|
8
|
+
export { SECURITY_ASSERTION_CODES } from './security-assertion-aspect.js';
|
|
9
|
+
export type { SecurityAssertionCode } from './security-assertion-aspect.js';
|
package/dist/aspects/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { TibTaggingAspect } from './tagging-aspect.js';
|
|
2
2
|
export { LogRetentionAspect } from './log-retention-aspect.js';
|
|
3
3
|
export { IamBoundariesAspect } from './iam-boundaries-aspect.js';
|
|
4
|
+
export { SecurityAssertionAspect } from './security-assertion-aspect.js';
|
|
5
|
+
export { SECURITY_ASSERTION_CODES } from './security-assertion-aspect.js';
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as cdk from 'aws-cdk-lib';
|
|
2
|
+
import { IConstruct } from 'constructs';
|
|
3
|
+
import type { DomainRegistry } from '../registry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Properties for {@link SecurityAssertionAspect}.
|
|
6
|
+
*
|
|
7
|
+
* The aspect reads the `DomainRegistry` exactly once at synthesis time and
|
|
8
|
+
* then walks every CDK construct for violations. All checks are read-only —
|
|
9
|
+
* the aspect never modifies the synthesized graph, only emits annotations.
|
|
10
|
+
*
|
|
11
|
+
* Issue #4662 Task D — deployment-time assertions. The CLI validator at
|
|
12
|
+
* `packages/domain-cli` already enforces the same rules against the
|
|
13
|
+
* `DomainRegistry` before synth, but the aspect is the last line of defence:
|
|
14
|
+
* it runs even if the CLI was skipped, and it can catch regressions in the
|
|
15
|
+
* synthesized graph itself (e.g. a route accidentally wired without an
|
|
16
|
+
* authorizer, or a Lambda exposed via Function URL).
|
|
17
|
+
*/
|
|
18
|
+
export interface SecurityAssertionAspectProps {
|
|
19
|
+
/** The compiled domain registry — source of truth for what was declared. */
|
|
20
|
+
registry: DomainRegistry;
|
|
21
|
+
/**
|
|
22
|
+
* When true, missing JWT authorizer on a JWT-required route is reported
|
|
23
|
+
* as a synth-blocking error rather than a warning. Default: true.
|
|
24
|
+
*
|
|
25
|
+
* Set to false during the Wave 4 Task 4.1 roll-out so we could ship
|
|
26
|
+
* fix-ups one stack at a time. New deployments should leave it on.
|
|
27
|
+
*/
|
|
28
|
+
failOnMissingAuthorizer?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* When true, an API-exposed action whose handler Lambda is reachable
|
|
31
|
+
* through a `Lambda::Url` resource is a synth-blocking error rather
|
|
32
|
+
* than a warning. Default: true — Function URLs create public, IAM-
|
|
33
|
+
* or NONE-authenticated endpoints that bypass API Gateway entirely.
|
|
34
|
+
*/
|
|
35
|
+
failOnFunctionUrl?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Result codes emitted by the aspect. Mirrored in the CLI validator so
|
|
39
|
+
* downstream tooling can correlate synth-time annotations with build-time
|
|
40
|
+
* validation errors. Each code corresponds to a single, narrowly-scoped
|
|
41
|
+
* invariant — see the per-method docstrings for the exact contract.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SECURITY_ASSERTION_CODES: {
|
|
44
|
+
/**
|
|
45
|
+
* A JWT-required route (api.authType='jwt' or action.exposure.auth='required')
|
|
46
|
+
* was synthesised without an attached `CfnAuthorizer` ID. Synth blocks.
|
|
47
|
+
*/
|
|
48
|
+
readonly MISSING_JWT_AUTHORIZER: "SECURITY_MISSING_JWT_AUTHORIZER";
|
|
49
|
+
/**
|
|
50
|
+
* A tenancy-required route (api requiring a tenant, or action.exposure.tenancy='required')
|
|
51
|
+
* does not include the canonical tenant placeholder
|
|
52
|
+
* (`/v1/tenants/{tenantId}/`) in its path. The runtime cannot bind
|
|
53
|
+
* `ctx.tenant.id` from a path that does not carry the placeholder, so
|
|
54
|
+
* this would silently produce a tenant-less route. Synth blocks.
|
|
55
|
+
*/
|
|
56
|
+
readonly MISSING_TENANT_PATH: "SECURITY_MISSING_TENANT_PATH";
|
|
57
|
+
/**
|
|
58
|
+
* An action Lambda is reachable through a `Lambda::Url` resource. This
|
|
59
|
+
* creates a public-or-IAM endpoint that bypasses API Gateway, the JWT
|
|
60
|
+
* authorizer, and the registry's exposure metadata. Synth blocks.
|
|
61
|
+
*/
|
|
62
|
+
readonly ACTION_FUNCTION_URL: "SECURITY_ACTION_FUNCTION_URL";
|
|
63
|
+
/**
|
|
64
|
+
* An API route is declared `auth: 'none'` or `authType: 'none'` without
|
|
65
|
+
* a `securityException.reason`. Public routes must document the
|
|
66
|
+
* exception so security reviewers can audit the relaxation. Synth
|
|
67
|
+
* blocks — anonymous routes are the exception, not the default.
|
|
68
|
+
*/
|
|
69
|
+
readonly MISSING_SECURITY_EXCEPTION: "SECURITY_MISSING_SECURITY_EXCEPTION";
|
|
70
|
+
};
|
|
71
|
+
export type SecurityAssertionCode = typeof SECURITY_ASSERTION_CODES[keyof typeof SECURITY_ASSERTION_CODES];
|
|
72
|
+
/**
|
|
73
|
+
* CDK Aspect that performs deployment-time security assertions against
|
|
74
|
+
* the synthesised {@link DomainStack}.
|
|
75
|
+
*
|
|
76
|
+
* The aspect complements the CLI validator at `packages/domain-cli`. Both
|
|
77
|
+
* layers run from the same source of truth (`DomainRegistry`), but they
|
|
78
|
+
* fail at different points in the pipeline:
|
|
79
|
+
*
|
|
80
|
+
* - The CLI validator fails the build BEFORE synth, so the developer
|
|
81
|
+
* gets a structured error code in their terminal and CI fails on
|
|
82
|
+
* `mc-domain-module validate`.
|
|
83
|
+
* - The aspect fails synth AFTER the stack has been assembled. It can
|
|
84
|
+
* detect runtime regressions (e.g. a route accidentally synthesised
|
|
85
|
+
* without an authorizer because a feature flag silently skipped the
|
|
86
|
+
* wiring) that the CLI cannot see.
|
|
87
|
+
*
|
|
88
|
+
* Adding the aspect to a stack is a one-liner:
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* cdk.Aspects.of(stack).add(new SecurityAssertionAspect({ registry }));
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* The {@link DomainStack} constructor already wires this aspect in for
|
|
95
|
+
* every domain, so most code does not need to interact with the class
|
|
96
|
+
* directly.
|
|
97
|
+
*/
|
|
98
|
+
export declare class SecurityAssertionAspect implements cdk.IAspect {
|
|
99
|
+
private readonly props;
|
|
100
|
+
constructor(props: SecurityAssertionAspectProps);
|
|
101
|
+
/**
|
|
102
|
+
* CDK calls this for every node in the construct tree. The aspect is
|
|
103
|
+
* intentionally narrow: it inspects the synthesised graph for the
|
|
104
|
+
* specific invariants listed above. It does NOT validate the registry
|
|
105
|
+
* itself — the CLI owns that responsibility and emitting duplicate
|
|
106
|
+
* errors here would be noisy.
|
|
107
|
+
*
|
|
108
|
+
* @param node - The CDK construct being visited.
|
|
109
|
+
*/
|
|
110
|
+
visit(node: IConstruct): void;
|
|
111
|
+
/**
|
|
112
|
+
* Walk every `CfnRoute` in the construct tree and verify the security
|
|
113
|
+
* invariants in one pass:
|
|
114
|
+
*
|
|
115
|
+
* 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
|
|
116
|
+
* Covers action API exposures (`exposure.auth === 'required'`).
|
|
117
|
+
* 2. Routes declared as tenant-required include the canonical
|
|
118
|
+
* `/v1/tenants/{tenantId}/` placeholder in their path.
|
|
119
|
+
*
|
|
120
|
+
* Both checks fail synth by default; the authorizer check can be
|
|
121
|
+
* downgraded to a warning via {@link SecurityAssertionAspectProps.failOnMissingAuthorizer}
|
|
122
|
+
* for incremental rollouts.
|
|
123
|
+
*/
|
|
124
|
+
private assertRouteSecurity;
|
|
125
|
+
/**
|
|
126
|
+
* Walk every `CfnUrl` in the construct tree and verify no action
|
|
127
|
+
* Lambda is reachable through a `Lambda::Url`. Wave 4 Task 4.2 (#4619)
|
|
128
|
+
* explicitly removed Function URL exposure because the resulting
|
|
129
|
+
* endpoints bypass API Gateway, the JWT authorizer, and the
|
|
130
|
+
* `backendAccess` / `exposure` policy gates.
|
|
131
|
+
*
|
|
132
|
+
* Health and readiness probes are explicitly exempted — they use
|
|
133
|
+
* `Lambda::Url` for cheap internal polling and the runtime never
|
|
134
|
+
* routes tenant traffic through them.
|
|
135
|
+
*/
|
|
136
|
+
private assertNoFunctionUrls;
|
|
137
|
+
/**
|
|
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.
|
|
142
|
+
*/
|
|
143
|
+
private assertAnonymousRoutesHaveException;
|
|
144
|
+
/**
|
|
145
|
+
* Find the registry action whose `exposure.path` matches a synthesised
|
|
146
|
+
* route for action API exposures.
|
|
147
|
+
*/
|
|
148
|
+
private findActionRegistryEntryForRoute;
|
|
149
|
+
/**
|
|
150
|
+
* Predicate — does the action registry entry carry a securityException
|
|
151
|
+
* on its exposure block?
|
|
152
|
+
*/
|
|
153
|
+
private hasActionSecurityException;
|
|
154
|
+
/**
|
|
155
|
+
* Predicate — does the path contain `/v1/tenants/{tenantId}/`?
|
|
156
|
+
*
|
|
157
|
+
* We do not use a regex because the placeholder is a literal string
|
|
158
|
+
* and the runtime extractor performs an identical literal match.
|
|
159
|
+
*/
|
|
160
|
+
private hasTenantPlaceholder;
|
|
161
|
+
}
|