@friggframework/devtools 2.0.0-next.101 → 2.0.0-next.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/frigg-cli/__tests__/unit/commands/generate-iam.test.js +97 -0
  2. package/frigg-cli/deploy-command/index.js +44 -0
  3. package/frigg-cli/generate-command/index.js +2 -1
  4. package/frigg-cli/generate-iam-command.js +2 -1
  5. package/frigg-cli/index.js +16 -1
  6. package/frigg-cli/ssm-command/index.js +308 -0
  7. package/frigg-cli/ssm-command/index.test.js +318 -0
  8. package/infrastructure/__tests__/helpers/test-utils.js +3 -5
  9. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  10. package/infrastructure/__tests__/ssm-preload-node-options.test.js +79 -0
  11. package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
  12. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
  13. package/infrastructure/domains/database/migration-builder.js +46 -9
  14. package/infrastructure/domains/database/migration-builder.test.js +101 -0
  15. package/infrastructure/domains/integration/integration-builder.js +65 -9
  16. package/infrastructure/domains/integration/integration-builder.test.js +95 -0
  17. package/infrastructure/domains/networking/vpc-builder.js +57 -7
  18. package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
  19. package/infrastructure/domains/networking/vpc-discovery.js +9 -2
  20. package/infrastructure/domains/networking/vpc-discovery.test.js +19 -1
  21. package/infrastructure/domains/networking/vpc-resolver.js +12 -3
  22. package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
  23. package/infrastructure/domains/parameters/offload-utils.js +190 -0
  24. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  25. package/infrastructure/domains/parameters/ssm-builder.js +67 -14
  26. package/infrastructure/domains/parameters/ssm-builder.test.js +158 -1
  27. package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
  28. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  29. package/infrastructure/domains/security/iam-generator.js +36 -1
  30. package/infrastructure/domains/security/iam-generator.test.js +83 -0
  31. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +17 -0
  32. package/infrastructure/domains/security/templates/iam-policy-full.json +8 -3
  33. package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
  34. package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
  35. package/infrastructure/domains/shared/environment-builder.js +88 -15
  36. package/infrastructure/domains/shared/environment-builder.test.js +304 -22
  37. package/infrastructure/domains/shared/function-environments.js +97 -0
  38. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  39. package/infrastructure/infrastructure-composer.js +48 -1
  40. package/infrastructure/infrastructure-composer.test.js +161 -15
  41. package/infrastructure/integration.test.js +3 -5
  42. package/package.json +8 -7
@@ -72,7 +72,7 @@ describe('SsmBuilder', () => {
72
72
  ssm: {
73
73
  enable: true,
74
74
  parameters: {
75
- DATABASE_URL: '/my-app/database-url',
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
- result.environment.SCHEDULER_ROLE_ARN = {
201
- 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
202
- };
203
- result.environment.SCHEDULE_GROUP_NAME = {
204
- Ref: 'FriggScheduleGroup',
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
- console.log(' ✓ Added scheduler environment variables');
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
- "StringEquals": {
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