@friggframework/devtools 2.0.0-next.100 → 2.0.0-next.102
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/frigg-cli/__tests__/unit/commands/generate-iam.test.js +97 -0
- package/frigg-cli/deploy-command/index.js +44 -0
- package/frigg-cli/generate-command/index.js +2 -1
- package/frigg-cli/generate-iam-command.js +2 -1
- package/frigg-cli/index.js +16 -1
- package/frigg-cli/ssm-command/index.js +308 -0
- package/frigg-cli/ssm-command/index.test.js +318 -0
- package/infrastructure/__tests__/helpers/test-utils.js +3 -5
- package/infrastructure/__tests__/scoped-environment.test.js +126 -0
- package/infrastructure/__tests__/ssm-preload-node-options.test.js +79 -0
- package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
- package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
- package/infrastructure/domains/database/migration-builder.js +46 -9
- package/infrastructure/domains/database/migration-builder.test.js +101 -0
- package/infrastructure/domains/integration/integration-builder.js +65 -9
- package/infrastructure/domains/integration/integration-builder.test.js +95 -0
- package/infrastructure/domains/networking/vpc-builder.js +57 -7
- package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
- package/infrastructure/domains/networking/vpc-discovery.js +9 -2
- package/infrastructure/domains/networking/vpc-discovery.test.js +19 -1
- package/infrastructure/domains/networking/vpc-resolver.js +12 -3
- package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
- package/infrastructure/domains/parameters/offload-utils.js +190 -0
- package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
- package/infrastructure/domains/parameters/ssm-builder.js +67 -14
- package/infrastructure/domains/parameters/ssm-builder.test.js +158 -1
- package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
- package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
- package/infrastructure/domains/security/iam-generator.js +36 -1
- package/infrastructure/domains/security/iam-generator.test.js +83 -0
- package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +17 -0
- package/infrastructure/domains/security/templates/iam-policy-full.json +8 -3
- package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
- package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
- package/infrastructure/domains/shared/environment-builder.js +43 -7
- package/infrastructure/domains/shared/environment-builder.test.js +97 -1
- package/infrastructure/domains/shared/function-environments.js +97 -0
- package/infrastructure/domains/shared/function-environments.test.js +146 -0
- package/infrastructure/infrastructure-composer.js +48 -1
- package/infrastructure/infrastructure-composer.test.js +161 -15
- package/infrastructure/integration.test.js +3 -5
- package/package.json +8 -7
|
@@ -72,7 +72,7 @@ describe('SsmBuilder', () => {
|
|
|
72
72
|
ssm: {
|
|
73
73
|
enable: true,
|
|
74
74
|
parameters: {
|
|
75
|
-
|
|
75
|
+
SERVICE_TOKEN: '/my-app/service-token',
|
|
76
76
|
API_KEY: '/my-app/api-key',
|
|
77
77
|
},
|
|
78
78
|
},
|
|
@@ -119,6 +119,42 @@ describe('SsmBuilder', () => {
|
|
|
119
119
|
expect(result.valid).toBe(false);
|
|
120
120
|
expect(result.errors.some(e => e.includes('ssm.parameters must be an object'))).toBe(true);
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
it('should error when keys are marked for offload but ssm.enable is not true', () => {
|
|
124
|
+
const appDefinition = {
|
|
125
|
+
ssm: { enable: false },
|
|
126
|
+
environment: { FOO: 'ssm' },
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = ssmBuilder.validate(appDefinition);
|
|
130
|
+
|
|
131
|
+
expect(result.valid).toBe(false);
|
|
132
|
+
expect(result.errors.some(e => e.includes('ssm.enable is not true'))).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should error when a blocklisted key is marked for offload', () => {
|
|
136
|
+
const appDefinition = {
|
|
137
|
+
ssm: { enable: true },
|
|
138
|
+
environment: { DATABASE_URL: 'ssm' },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const result = ssmBuilder.validate(appDefinition);
|
|
142
|
+
|
|
143
|
+
expect(result.valid).toBe(false);
|
|
144
|
+
expect(result.errors.some(e => e.includes('DATABASE_URL'))).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should error when an invalid env name is marked for offload', () => {
|
|
148
|
+
const appDefinition = {
|
|
149
|
+
ssm: { enable: true },
|
|
150
|
+
environment: { 'bad-name': 'ssm' },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const result = ssmBuilder.validate(appDefinition);
|
|
154
|
+
|
|
155
|
+
expect(result.valid).toBe(false);
|
|
156
|
+
expect(result.errors.some(e => e.includes('bad-name'))).toBe(true);
|
|
157
|
+
});
|
|
122
158
|
});
|
|
123
159
|
|
|
124
160
|
describe('build()', () => {
|
|
@@ -170,6 +206,127 @@ describe('SsmBuilder', () => {
|
|
|
170
206
|
|
|
171
207
|
expect(result1.iamStatements).toEqual(result2.iamStatements);
|
|
172
208
|
});
|
|
209
|
+
|
|
210
|
+
it('should return empty environment when no keys are offloaded', async () => {
|
|
211
|
+
const appDefinition = {
|
|
212
|
+
ssm: { enable: true },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
216
|
+
|
|
217
|
+
expect(result.environment).toEqual({});
|
|
218
|
+
expect(result.iamStatements).toHaveLength(1);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('build() - offload active', () => {
|
|
223
|
+
it('should add prefix and offloaded keys env vars', async () => {
|
|
224
|
+
const appDefinition = {
|
|
225
|
+
ssm: { enable: true },
|
|
226
|
+
environment: { FOO: 'ssm', BAR: 'ssm' },
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
230
|
+
|
|
231
|
+
expect(result.environment.SSM_PARAMETER_PREFIX).toBe(
|
|
232
|
+
'/frigg/${self:service}/${self:provider.stage}'
|
|
233
|
+
);
|
|
234
|
+
expect(result.environment.FRIGG_SSM_OFFLOADED_KEYS).toBe('BAR,FOO');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('does not set NODE_OPTIONS globally (the composer scopes it to skipEsbuild functions)', async () => {
|
|
238
|
+
const appDefinition = {
|
|
239
|
+
ssm: { enable: true },
|
|
240
|
+
environment: { FOO: 'ssm' },
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
244
|
+
|
|
245
|
+
// NODE_OPTIONS=--import would fatally abort esbuild-bundled functions
|
|
246
|
+
// that lack the preload file, so it must never be provider-global.
|
|
247
|
+
expect(result.environment.NODE_OPTIONS).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should add a prefix-scoped read statement while retaining the broad grant by default', async () => {
|
|
251
|
+
const appDefinition = {
|
|
252
|
+
ssm: { enable: true },
|
|
253
|
+
environment: { FOO: 'ssm' },
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
257
|
+
|
|
258
|
+
const broad = result.iamStatements.find(
|
|
259
|
+
s => s.Resource && s.Resource['Fn::Sub'] === 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*'
|
|
260
|
+
);
|
|
261
|
+
expect(broad).toBeDefined();
|
|
262
|
+
|
|
263
|
+
const scoped = result.iamStatements.find(
|
|
264
|
+
s => s.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/frigg/${self:service}/${self:provider.stage}/*'
|
|
265
|
+
);
|
|
266
|
+
expect(scoped).toBeDefined();
|
|
267
|
+
expect(scoped.Action).toEqual([
|
|
268
|
+
'ssm:GetParameter',
|
|
269
|
+
'ssm:GetParameters',
|
|
270
|
+
'ssm:GetParametersByPath',
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should drop the broad grant when restrictIamToPrefix is set', async () => {
|
|
275
|
+
const appDefinition = {
|
|
276
|
+
ssm: { enable: true, restrictIamToPrefix: true },
|
|
277
|
+
environment: { FOO: 'ssm' },
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
281
|
+
|
|
282
|
+
const broad = result.iamStatements.find(
|
|
283
|
+
s => s.Resource && s.Resource['Fn::Sub']
|
|
284
|
+
);
|
|
285
|
+
expect(broad).toBeUndefined();
|
|
286
|
+
|
|
287
|
+
const scoped = result.iamStatements.find(
|
|
288
|
+
s => s.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/frigg/${self:service}/${self:provider.stage}/*'
|
|
289
|
+
);
|
|
290
|
+
expect(scoped).toBeDefined();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should add kms:Decrypt when kmsKeyArn is set', async () => {
|
|
294
|
+
const appDefinition = {
|
|
295
|
+
ssm: {
|
|
296
|
+
enable: true,
|
|
297
|
+
kmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
|
|
298
|
+
},
|
|
299
|
+
environment: { FOO: 'ssm' },
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
303
|
+
|
|
304
|
+
const decrypt = result.iamStatements.find(
|
|
305
|
+
s => Array.isArray(s.Action) && s.Action.includes('kms:Decrypt')
|
|
306
|
+
);
|
|
307
|
+
expect(decrypt).toEqual({
|
|
308
|
+
Effect: 'Allow',
|
|
309
|
+
Action: ['kms:Decrypt'],
|
|
310
|
+
Resource: 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should honor a custom parameterPrefix', async () => {
|
|
315
|
+
const appDefinition = {
|
|
316
|
+
ssm: { enable: true, parameterPrefix: '/custom/${self:provider.stage}' },
|
|
317
|
+
environment: { FOO: 'ssm' },
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = await ssmBuilder.build(appDefinition, {});
|
|
321
|
+
|
|
322
|
+
expect(result.environment.SSM_PARAMETER_PREFIX).toBe(
|
|
323
|
+
'/custom/${self:provider.stage}'
|
|
324
|
+
);
|
|
325
|
+
const scoped = result.iamStatements.find(
|
|
326
|
+
s => s.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/custom/${self:provider.stage}/*'
|
|
327
|
+
);
|
|
328
|
+
expect(scoped).toBeDefined();
|
|
329
|
+
});
|
|
173
330
|
});
|
|
174
331
|
|
|
175
332
|
describe('getDependencies()', () => {
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
|
|
16
|
+
const {
|
|
17
|
+
isScopedEnvironmentActive,
|
|
18
|
+
getIntegrationFunctionNames,
|
|
19
|
+
getAdminFunctionNames,
|
|
20
|
+
} = require('../shared/function-environments');
|
|
16
21
|
|
|
17
22
|
class SchedulerBuilder extends InfrastructureBuilder {
|
|
18
23
|
constructor() {
|
|
@@ -70,7 +75,7 @@ class SchedulerBuilder extends InfrastructureBuilder {
|
|
|
70
75
|
this.addSchedulerIamStatements(result);
|
|
71
76
|
|
|
72
77
|
// Add environment variables
|
|
73
|
-
this.addEnvironmentVariables(result);
|
|
78
|
+
this.addEnvironmentVariables(result, appDefinition);
|
|
74
79
|
|
|
75
80
|
console.log(`[${this.name}] ✅ Scheduler configuration completed`);
|
|
76
81
|
return result;
|
|
@@ -196,15 +201,46 @@ class SchedulerBuilder extends InfrastructureBuilder {
|
|
|
196
201
|
/**
|
|
197
202
|
* Add environment variables for scheduler configuration
|
|
198
203
|
*/
|
|
199
|
-
addEnvironmentVariables(result) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
addEnvironmentVariables(result, appDefinition = {}) {
|
|
205
|
+
const environment = {
|
|
206
|
+
SCHEDULER_ROLE_ARN: {
|
|
207
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
208
|
+
},
|
|
209
|
+
SCHEDULE_GROUP_NAME: {
|
|
210
|
+
Ref: 'FriggScheduleGroup',
|
|
211
|
+
},
|
|
205
212
|
};
|
|
206
213
|
|
|
207
|
-
|
|
214
|
+
if (!isScopedEnvironmentActive(appDefinition)) {
|
|
215
|
+
Object.assign(result.environment, environment);
|
|
216
|
+
console.log(' ✓ Added scheduler environment variables');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Consumers: auth (runs integration actions), the executor (runs
|
|
221
|
+
// admin scripts that instantiate integrations), and every
|
|
222
|
+
// integration function. NOT adminScriptRouter — it carries its own
|
|
223
|
+
// admin-scheduler role, set directly by the admin-script builder.
|
|
224
|
+
const targets = [
|
|
225
|
+
'auth',
|
|
226
|
+
...getAdminFunctionNames(appDefinition).filter(
|
|
227
|
+
(fnName) => fnName !== 'adminScriptRouter'
|
|
228
|
+
),
|
|
229
|
+
...(appDefinition.integrations || []).flatMap(
|
|
230
|
+
getIntegrationFunctionNames
|
|
231
|
+
),
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
235
|
+
for (const fnName of targets) {
|
|
236
|
+
result.functionEnvironments[fnName] = {
|
|
237
|
+
...result.functionEnvironments[fnName],
|
|
238
|
+
...environment,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
console.log(
|
|
242
|
+
` ✓ Scoped scheduler environment variables to: ${targets.join(', ')}`
|
|
243
|
+
);
|
|
208
244
|
}
|
|
209
245
|
}
|
|
210
246
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Scheduler Builder
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { SchedulerBuilder } = require('./scheduler-builder');
|
|
6
|
+
|
|
7
|
+
describe('SchedulerBuilder', () => {
|
|
8
|
+
let schedulerBuilder;
|
|
9
|
+
const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
schedulerBuilder = new SchedulerBuilder();
|
|
13
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (originalSkipDiscovery === undefined) {
|
|
18
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('shouldExecute()', () => {
|
|
25
|
+
it('runs when explicitly enabled', () => {
|
|
26
|
+
expect(
|
|
27
|
+
schedulerBuilder.shouldExecute({ scheduler: { enable: true } })
|
|
28
|
+
).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('runs when any integration has webhooks', () => {
|
|
32
|
+
expect(
|
|
33
|
+
schedulerBuilder.shouldExecute({
|
|
34
|
+
integrations: [
|
|
35
|
+
{ Definition: { name: 'a', webhooks: true } },
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('skips otherwise', () => {
|
|
42
|
+
expect(
|
|
43
|
+
schedulerBuilder.shouldExecute({
|
|
44
|
+
integrations: [{ Definition: { name: 'a' } }],
|
|
45
|
+
})
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('build() environment', () => {
|
|
51
|
+
const scheduledApp = (extra = {}) => ({
|
|
52
|
+
scheduler: { enable: true },
|
|
53
|
+
integrations: [
|
|
54
|
+
{ Definition: { name: 'hubspot', webhooks: true } },
|
|
55
|
+
{ Definition: { name: 'slack' } },
|
|
56
|
+
],
|
|
57
|
+
...extra,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('broadcasts scheduler vars app-wide by default', async () => {
|
|
61
|
+
const result = await schedulerBuilder.build(scheduledApp(), {});
|
|
62
|
+
|
|
63
|
+
expect(result.environment.SCHEDULER_ROLE_ARN).toEqual({
|
|
64
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
65
|
+
});
|
|
66
|
+
expect(result.environment.SCHEDULE_GROUP_NAME).toEqual({
|
|
67
|
+
Ref: 'FriggScheduleGroup',
|
|
68
|
+
});
|
|
69
|
+
expect(result.functionEnvironments).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('scopes scheduler vars to auth, adminScriptExecutor, and integration functions with lambda.scopedEnvironment', async () => {
|
|
73
|
+
const result = await schedulerBuilder.build(
|
|
74
|
+
scheduledApp({
|
|
75
|
+
lambda: { scopedEnvironment: true },
|
|
76
|
+
adminScripts: [{ Definition: { name: 'fix-things' } }],
|
|
77
|
+
}),
|
|
78
|
+
{}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result.environment.SCHEDULER_ROLE_ARN).toBeUndefined();
|
|
82
|
+
expect(result.environment.SCHEDULE_GROUP_NAME).toBeUndefined();
|
|
83
|
+
|
|
84
|
+
const scoped = result.functionEnvironments;
|
|
85
|
+
for (const fnName of [
|
|
86
|
+
'auth',
|
|
87
|
+
'adminScriptExecutor',
|
|
88
|
+
'hubspot',
|
|
89
|
+
'hubspotWebhook',
|
|
90
|
+
'hubspotQueueWorker',
|
|
91
|
+
'slack',
|
|
92
|
+
'slackQueueWorker',
|
|
93
|
+
]) {
|
|
94
|
+
expect(scoped[fnName].SCHEDULER_ROLE_ARN).toEqual({
|
|
95
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
96
|
+
});
|
|
97
|
+
expect(scoped[fnName].SCHEDULE_GROUP_NAME).toEqual({
|
|
98
|
+
Ref: 'FriggScheduleGroup',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// the router keeps its own admin-scheduler role, set directly by
|
|
103
|
+
// the admin-script builder — never targeted here
|
|
104
|
+
expect(scoped.adminScriptRouter).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps broadcasting in local mode even with the flag on', async () => {
|
|
108
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
|
|
109
|
+
const result = await schedulerBuilder.build(
|
|
110
|
+
scheduledApp({ lambda: { scopedEnvironment: true } }),
|
|
111
|
+
{}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(result.environment.SCHEDULER_ROLE_ARN).toBeDefined();
|
|
115
|
+
expect(result.functionEnvironments).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -7,6 +7,7 @@ const path = require('path');
|
|
|
7
7
|
* @param {Object} [options.features={}] - Enabled features { vpc, kms, ssm, websockets }
|
|
8
8
|
* @param {string} [options.userPrefix='frigg-deployment-user'] - IAM user name prefix
|
|
9
9
|
* @param {string} [options.stackName='frigg-deployment-iam'] - CloudFormation stack name
|
|
10
|
+
* @param {string} [options.ssmKmsKeyArn] - Customer-managed KMS key ARN for SecureString offload (appDefinition.ssm.kmsKeyArn)
|
|
10
11
|
* @returns {string} CloudFormation YAML template
|
|
11
12
|
*/
|
|
12
13
|
function generateIAMCloudFormation(options = {}) {
|
|
@@ -14,7 +15,8 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
14
15
|
appName = 'Frigg',
|
|
15
16
|
features = {},
|
|
16
17
|
userPrefix = 'frigg-deployment-user',
|
|
17
|
-
stackName = 'frigg-deployment-iam'
|
|
18
|
+
stackName = 'frigg-deployment-iam',
|
|
19
|
+
ssmKmsKeyArn
|
|
18
20
|
} = options;
|
|
19
21
|
|
|
20
22
|
const deploymentUserName = userPrefix;
|
|
@@ -667,6 +669,9 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
667
669
|
'ssm:GetParameter',
|
|
668
670
|
'ssm:GetParameters',
|
|
669
671
|
'ssm:GetParametersByPath',
|
|
672
|
+
'ssm:PutParameter',
|
|
673
|
+
'ssm:DeleteParameter',
|
|
674
|
+
'ssm:AddTagsToResource',
|
|
670
675
|
],
|
|
671
676
|
Resource: [
|
|
672
677
|
{
|
|
@@ -679,6 +684,35 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
679
684
|
},
|
|
680
685
|
],
|
|
681
686
|
},
|
|
687
|
+
{
|
|
688
|
+
// SecureString offload: SSM performs the KMS encrypt/decrypt
|
|
689
|
+
// on the caller's behalf, so the grant is scoped to ViaService ssm.*
|
|
690
|
+
Sid: 'FriggSSMParameterKMSEncryption',
|
|
691
|
+
Effect: 'Allow',
|
|
692
|
+
Action: [
|
|
693
|
+
'kms:Encrypt',
|
|
694
|
+
'kms:GenerateDataKey',
|
|
695
|
+
'kms:Decrypt',
|
|
696
|
+
],
|
|
697
|
+
Resource: ssmKmsKeyArn
|
|
698
|
+
? [ssmKmsKeyArn]
|
|
699
|
+
: [
|
|
700
|
+
{
|
|
701
|
+
'Fn::Sub':
|
|
702
|
+
'arn:aws:kms:*:${AWS::AccountId}:key/*',
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
Condition: {
|
|
706
|
+
// StringLike: kms:ViaService is regional
|
|
707
|
+
// (ssm.us-east-1.amazonaws.com), so the wildcard
|
|
708
|
+
// must be matched, not compared literally.
|
|
709
|
+
StringLike: {
|
|
710
|
+
'kms:ViaService': [
|
|
711
|
+
'ssm.*.amazonaws.com',
|
|
712
|
+
],
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
},
|
|
682
716
|
],
|
|
683
717
|
},
|
|
684
718
|
},
|
|
@@ -773,6 +807,7 @@ function getFeatureSummary(appDefinition) {
|
|
|
773
807
|
features,
|
|
774
808
|
integrationCount,
|
|
775
809
|
appName: appDefinition.name || 'Unnamed Frigg App',
|
|
810
|
+
ssmKmsKeyArn: appDefinition.ssm?.kmsKeyArn,
|
|
776
811
|
};
|
|
777
812
|
}
|
|
778
813
|
|
|
@@ -38,6 +38,34 @@ describe('IAM Generator', () => {
|
|
|
38
38
|
expect(summary.features.ssm).toBe(false);
|
|
39
39
|
expect(summary.features.websockets).toBe(false);
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
it('should surface ssm.kmsKeyArn from the app definition', () => {
|
|
43
|
+
const appDefinition = {
|
|
44
|
+
name: 'test-app',
|
|
45
|
+
ssm: {
|
|
46
|
+
enable: true,
|
|
47
|
+
kmsKeyArn:
|
|
48
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const summary = getFeatureSummary(appDefinition);
|
|
53
|
+
|
|
54
|
+
expect(summary.ssmKmsKeyArn).toBe(
|
|
55
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should leave ssmKmsKeyArn undefined when not configured', () => {
|
|
60
|
+
const appDefinition = {
|
|
61
|
+
name: 'test-app',
|
|
62
|
+
ssm: { enable: true }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const summary = getFeatureSummary(appDefinition);
|
|
66
|
+
|
|
67
|
+
expect(summary.ssmKmsKeyArn).toBeUndefined();
|
|
68
|
+
});
|
|
41
69
|
});
|
|
42
70
|
|
|
43
71
|
describe('generateIAMCloudFormation', () => {
|
|
@@ -120,6 +148,61 @@ describe('IAM Generator', () => {
|
|
|
120
148
|
expect(yaml).toContain('EnableSSMSupport');
|
|
121
149
|
});
|
|
122
150
|
|
|
151
|
+
it('should grant SSM-mediated KMS access via ssm.*.amazonaws.com when SSM is enabled', () => {
|
|
152
|
+
const appDefinition = {
|
|
153
|
+
name: 'test-app',
|
|
154
|
+
integrations: [],
|
|
155
|
+
ssm: { enable: true }
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const summary = getFeatureSummary(appDefinition);
|
|
159
|
+
const yaml = generateIAMCloudFormation({
|
|
160
|
+
appName: summary.appName,
|
|
161
|
+
features: summary.features
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(yaml).toContain('FriggSSMParameterKMSEncryption');
|
|
165
|
+
expect(yaml).toContain('kms:Encrypt');
|
|
166
|
+
expect(yaml).toContain('ssm.*.amazonaws.com');
|
|
167
|
+
// No customer-managed key configured: falls back to the account key wildcard
|
|
168
|
+
expect(yaml).toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
|
|
169
|
+
|
|
170
|
+
// The regional ViaService wildcard must be matched with StringLike;
|
|
171
|
+
// StringEquals would compare literally and never match
|
|
172
|
+
// ssm.<region>.amazonaws.com, denying the SecureString KMS call.
|
|
173
|
+
const ssmKmsBlock = yaml.slice(
|
|
174
|
+
yaml.indexOf('FriggSSMParameterKMSEncryption'),
|
|
175
|
+
yaml.indexOf('FriggSSMParameterKMSEncryption') + 600
|
|
176
|
+
);
|
|
177
|
+
expect(ssmKmsBlock).toContain('StringLike');
|
|
178
|
+
expect(ssmKmsBlock).not.toContain('StringEquals');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should scope the SSM KMS grant to ssm.kmsKeyArn when provided', () => {
|
|
182
|
+
const appDefinition = {
|
|
183
|
+
name: 'test-app',
|
|
184
|
+
integrations: [],
|
|
185
|
+
ssm: {
|
|
186
|
+
enable: true,
|
|
187
|
+
kmsKeyArn:
|
|
188
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const summary = getFeatureSummary(appDefinition);
|
|
193
|
+
const yaml = generateIAMCloudFormation({
|
|
194
|
+
appName: summary.appName,
|
|
195
|
+
features: summary.features,
|
|
196
|
+
ssmKmsKeyArn: appDefinition.ssm.kmsKeyArn
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(yaml).toContain('FriggSSMParameterKMSEncryption');
|
|
200
|
+
expect(yaml).toContain(
|
|
201
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
202
|
+
);
|
|
203
|
+
expect(yaml).not.toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
|
|
204
|
+
});
|
|
205
|
+
|
|
123
206
|
it('should set correct default parameter values based on features', () => {
|
|
124
207
|
const appDefinition = {
|
|
125
208
|
name: 'test-app',
|
|
@@ -360,9 +360,26 @@ Resources:
|
|
|
360
360
|
- 'ssm:GetParameter'
|
|
361
361
|
- 'ssm:GetParameters'
|
|
362
362
|
- 'ssm:GetParametersByPath'
|
|
363
|
+
- 'ssm:PutParameter'
|
|
364
|
+
- 'ssm:DeleteParameter'
|
|
365
|
+
- 'ssm:AddTagsToResource'
|
|
363
366
|
Resource:
|
|
364
367
|
- !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*frigg*'
|
|
365
368
|
- !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*frigg*/*'
|
|
369
|
+
- Sid: 'FriggSSMParameterKMSEncryption'
|
|
370
|
+
Effect: Allow
|
|
371
|
+
Action:
|
|
372
|
+
- 'kms:Encrypt'
|
|
373
|
+
- 'kms:GenerateDataKey'
|
|
374
|
+
- 'kms:Decrypt'
|
|
375
|
+
Resource:
|
|
376
|
+
- !Sub 'arn:aws:kms:*:${AWS::AccountId}:key/*'
|
|
377
|
+
Condition:
|
|
378
|
+
# StringLike: kms:ViaService is regional (ssm.us-east-1.amazonaws.com),
|
|
379
|
+
# so the wildcard must be matched, not compared literally.
|
|
380
|
+
StringLike:
|
|
381
|
+
'kms:ViaService':
|
|
382
|
+
- 'ssm.*.amazonaws.com'
|
|
366
383
|
|
|
367
384
|
# Store access key in Secrets Manager
|
|
368
385
|
FriggDeploymentCredentials:
|
|
@@ -256,6 +256,7 @@
|
|
|
256
256
|
"Sid": "FriggKMSEncryptionPermissions",
|
|
257
257
|
"Effect": "Allow",
|
|
258
258
|
"Action": [
|
|
259
|
+
"kms:Encrypt",
|
|
259
260
|
"kms:GenerateDataKey",
|
|
260
261
|
"kms:Decrypt"
|
|
261
262
|
],
|
|
@@ -263,10 +264,11 @@
|
|
|
263
264
|
"arn:aws:kms:*:*:key/*"
|
|
264
265
|
],
|
|
265
266
|
"Condition": {
|
|
266
|
-
"
|
|
267
|
+
"StringLike": {
|
|
267
268
|
"kms:ViaService": [
|
|
268
269
|
"lambda.*.amazonaws.com",
|
|
269
|
-
"s3.*.amazonaws.com"
|
|
270
|
+
"s3.*.amazonaws.com",
|
|
271
|
+
"ssm.*.amazonaws.com"
|
|
270
272
|
]
|
|
271
273
|
}
|
|
272
274
|
}
|
|
@@ -277,7 +279,10 @@
|
|
|
277
279
|
"Action": [
|
|
278
280
|
"ssm:GetParameter",
|
|
279
281
|
"ssm:GetParameters",
|
|
280
|
-
"ssm:GetParametersByPath"
|
|
282
|
+
"ssm:GetParametersByPath",
|
|
283
|
+
"ssm:PutParameter",
|
|
284
|
+
"ssm:DeleteParameter",
|
|
285
|
+
"ssm:AddTagsToResource"
|
|
281
286
|
],
|
|
282
287
|
"Resource": [
|
|
283
288
|
"arn:aws:ssm:*:*:parameter/*frigg*",
|
|
@@ -144,6 +144,7 @@ class BuilderOrchestrator {
|
|
|
144
144
|
iamStatements: [],
|
|
145
145
|
environment: {},
|
|
146
146
|
functions: {},
|
|
147
|
+
functionEnvironments: {},
|
|
147
148
|
layers: {},
|
|
148
149
|
plugins: [],
|
|
149
150
|
custom: {},
|
|
@@ -172,6 +173,19 @@ class BuilderOrchestrator {
|
|
|
172
173
|
Object.assign(merged.functions, result.functions);
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// Merge function-scoped environment maps (applied by the
|
|
177
|
+
// composer once base + builder functions all exist)
|
|
178
|
+
if (result.functionEnvironments) {
|
|
179
|
+
for (const [fnName, env] of Object.entries(
|
|
180
|
+
result.functionEnvironments
|
|
181
|
+
)) {
|
|
182
|
+
merged.functionEnvironments[fnName] = {
|
|
183
|
+
...merged.functionEnvironments[fnName],
|
|
184
|
+
...env,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
175
189
|
// Merge layers
|
|
176
190
|
if (result.layers) {
|
|
177
191
|
Object.assign(merged.layers, result.layers);
|
|
@@ -208,6 +208,51 @@ describe('BuilderOrchestrator', () => {
|
|
|
208
208
|
|
|
209
209
|
await expect(orchestrator.buildAll({})).rejects.toThrow('Build failed');
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
it('should merge functionEnvironments per function, later builder winning on key conflict', async () => {
|
|
213
|
+
class ScopedEnvBuilderA extends InfrastructureBuilder {
|
|
214
|
+
getName() { return 'ScopedEnvBuilderA'; }
|
|
215
|
+
shouldExecute() { return true; }
|
|
216
|
+
validate() { return new ValidationResult(); }
|
|
217
|
+
async build() {
|
|
218
|
+
return {
|
|
219
|
+
functionEnvironments: {
|
|
220
|
+
auth: { QUEUE_A: 'url-a', SHARED: 'from-a' },
|
|
221
|
+
workerA: { QUEUE_A: 'url-a' },
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
class ScopedEnvBuilderB extends InfrastructureBuilder {
|
|
227
|
+
getName() { return 'ScopedEnvBuilderB'; }
|
|
228
|
+
shouldExecute() { return true; }
|
|
229
|
+
validate() { return new ValidationResult(); }
|
|
230
|
+
getDependencies() { return ['ScopedEnvBuilderA']; }
|
|
231
|
+
async build() {
|
|
232
|
+
return {
|
|
233
|
+
functionEnvironments: {
|
|
234
|
+
auth: { QUEUE_B: 'url-b', SHARED: 'from-b' },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
orchestrator = new BuilderOrchestrator([
|
|
241
|
+
new ScopedEnvBuilderA(),
|
|
242
|
+
new ScopedEnvBuilderB(),
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
const result = await orchestrator.buildAll({});
|
|
246
|
+
|
|
247
|
+
expect(result.merged.functionEnvironments).toEqual({
|
|
248
|
+
auth: {
|
|
249
|
+
QUEUE_A: 'url-a',
|
|
250
|
+
QUEUE_B: 'url-b',
|
|
251
|
+
SHARED: 'from-b',
|
|
252
|
+
},
|
|
253
|
+
workerA: { QUEUE_A: 'url-a' },
|
|
254
|
+
});
|
|
255
|
+
});
|
|
211
256
|
});
|
|
212
257
|
});
|
|
213
258
|
|