@friggframework/devtools 2.0.0--canary.623.b39b42d.0 → 2.0.0--canary.625.950ba82.0

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 (39) 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 +270 -0
  7. package/frigg-cli/ssm-command/index.test.js +301 -0
  8. package/infrastructure/__tests__/helpers/test-utils.js +3 -5
  9. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  10. package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
  11. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
  12. package/infrastructure/domains/database/migration-builder.js +46 -9
  13. package/infrastructure/domains/database/migration-builder.test.js +101 -0
  14. package/infrastructure/domains/integration/integration-builder.js +65 -9
  15. package/infrastructure/domains/integration/integration-builder.test.js +95 -0
  16. package/infrastructure/domains/networking/vpc-builder.js +57 -7
  17. package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
  18. package/infrastructure/domains/networking/vpc-resolver.js +12 -3
  19. package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
  20. package/infrastructure/domains/parameters/offload-utils.js +175 -0
  21. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  22. package/infrastructure/domains/parameters/ssm-builder.js +60 -14
  23. package/infrastructure/domains/parameters/ssm-builder.test.js +145 -1
  24. package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
  25. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  26. package/infrastructure/domains/security/iam-generator.js +33 -1
  27. package/infrastructure/domains/security/iam-generator.test.js +73 -0
  28. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +15 -0
  29. package/infrastructure/domains/security/templates/iam-policy-full.json +7 -2
  30. package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
  31. package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
  32. package/infrastructure/domains/shared/environment-builder.js +43 -7
  33. package/infrastructure/domains/shared/environment-builder.test.js +97 -1
  34. package/infrastructure/domains/shared/function-environments.js +97 -0
  35. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  36. package/infrastructure/infrastructure-composer.js +5 -0
  37. package/infrastructure/infrastructure-composer.test.js +69 -14
  38. package/infrastructure/integration.test.js +3 -5
  39. package/package.json +8 -7
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SSM Offload Utilities
3
+ *
4
+ * Shared helpers for the SSM Parameter Store offload feature. A variable is
5
+ * "offloaded" when its value lives in Parameter Store and is fetched by the
6
+ * runtime at cold start instead of being baked into the Lambda environment
7
+ * (which counts against the hard 4KB env-var limit).
8
+ *
9
+ * Consumed by the SsmBuilder, the environment builder, and the
10
+ * `frigg ssm push` CLI command so they all agree on the offload key set and
11
+ * parameter naming.
12
+ */
13
+
14
+ const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
15
+
16
+ const DEFAULT_PARAMETER_PREFIX =
17
+ '/frigg/${self:service}/${self:provider.stage}';
18
+
19
+ // Keys the framework itself sets (from discovered resources or the base
20
+ // template) or that the loader/bypass handlers depend on before SSM values
21
+ // are available. Never offloadable.
22
+ const FRAMEWORK_ENV_BLOCKLIST = new Set([
23
+ 'STAGE',
24
+ 'FRIGG_STACK',
25
+ 'FRIGG_STAGE',
26
+ 'FRIGG_REGION',
27
+ 'KMS_KEY_ARN',
28
+ 'AES_KEY',
29
+ 'AES_KEY_ID',
30
+ 'DATABASE_URL',
31
+ 'DATABASE_HOST',
32
+ 'DATABASE_PORT',
33
+ 'DATABASE_USER',
34
+ 'DATABASE_PASSWORD',
35
+ 'DATABASE_SECRET_ARN',
36
+ 'DB_TYPE',
37
+ 'MONGO_URI',
38
+ 'SECRET_ARN',
39
+ 'SSM_PARAMETER_PREFIX',
40
+ 'FRIGG_SSM_OFFLOADED_KEYS',
41
+ 'FRIGG_SSM_CACHE_TTL',
42
+ 'WORKER_FUNCTION_NAME',
43
+ // Reserved AWS Lambda runtime variables
44
+ '_HANDLER',
45
+ '_X_AMZN_TRACE_ID',
46
+ 'AWS_DEFAULT_REGION',
47
+ 'AWS_EXECUTION_ENV',
48
+ 'AWS_REGION',
49
+ 'AWS_LAMBDA_FUNCTION_NAME',
50
+ 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE',
51
+ 'AWS_LAMBDA_FUNCTION_VERSION',
52
+ 'AWS_LAMBDA_INITIALIZATION_TYPE',
53
+ 'AWS_LAMBDA_LOG_GROUP_NAME',
54
+ 'AWS_LAMBDA_LOG_STREAM_NAME',
55
+ 'AWS_ACCESS_KEY',
56
+ 'AWS_ACCESS_KEY_ID',
57
+ 'AWS_SECRET_ACCESS_KEY',
58
+ 'AWS_SESSION_TOKEN',
59
+ ]);
60
+
61
+ /**
62
+ * Union of keys marked for SSM offload, sorted for deterministic output.
63
+ * Sources: appDefinition.environment values === 'ssm' and
64
+ * appDefinition.ssm.parameters keys. Does NOT filter invalid/blocklisted
65
+ * keys — use validateOffloadConfig() to surface those as errors instead of
66
+ * silently dropping them.
67
+ *
68
+ * @param {Object} appDefinition
69
+ * @returns {string[]}
70
+ */
71
+ function getOffloadedKeys(appDefinition = {}) {
72
+ const keys = new Set();
73
+
74
+ for (const [key, value] of Object.entries(
75
+ appDefinition.environment || {}
76
+ )) {
77
+ if (value === 'ssm') keys.add(key);
78
+ }
79
+
80
+ for (const key of Object.keys(appDefinition.ssm?.parameters || {})) {
81
+ keys.add(key);
82
+ }
83
+
84
+ return [...keys].sort();
85
+ }
86
+
87
+ /**
88
+ * Parameter path prefix under which offloaded values are stored. The default
89
+ * uses serverless variables resolved at package time and stays inside the
90
+ * *frigg* scope of the generated deployment IAM policies.
91
+ *
92
+ * @param {Object} appDefinition
93
+ * @returns {string}
94
+ */
95
+ function getParameterPrefix(appDefinition = {}) {
96
+ const prefix =
97
+ appDefinition.ssm?.parameterPrefix || DEFAULT_PARAMETER_PREFIX;
98
+ return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
99
+ }
100
+
101
+ /**
102
+ * Resolve the parameter prefix to a concrete path outside a serverless
103
+ * build (e.g. in the CLI), substituting the serverless variables the
104
+ * default prefix uses.
105
+ *
106
+ * @param {Object} appDefinition
107
+ * @param {string} stage
108
+ * @returns {string}
109
+ */
110
+ function resolveParameterPrefix(appDefinition = {}, stage) {
111
+ return getParameterPrefix(appDefinition)
112
+ .replaceAll('${self:service}', appDefinition.name)
113
+ .replaceAll('${self:provider.stage}', stage);
114
+ }
115
+
116
+ /**
117
+ * Whether the offload feature is active for this build: SSM enabled, at
118
+ * least one key marked, and not running in local mode.
119
+ *
120
+ * @param {Object} appDefinition
121
+ * @returns {boolean}
122
+ */
123
+ function isSsmOffloadActive(appDefinition = {}) {
124
+ if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') return false;
125
+ if (appDefinition.ssm?.enable !== true) return false;
126
+ return getOffloadedKeys(appDefinition).length > 0;
127
+ }
128
+
129
+ /**
130
+ * Validate the offload configuration. Returns errors (not warnings) for
131
+ * misconfigurations that would silently lose variables at runtime.
132
+ *
133
+ * @param {Object} appDefinition
134
+ * @returns {{errors: string[]}}
135
+ */
136
+ function validateOffloadConfig(appDefinition = {}) {
137
+ const errors = [];
138
+ const offloadedKeys = getOffloadedKeys(appDefinition);
139
+
140
+ if (offloadedKeys.length === 0) {
141
+ return { errors };
142
+ }
143
+
144
+ if (appDefinition.ssm?.enable !== true) {
145
+ errors.push(
146
+ `Environment keys are marked for SSM offload (${offloadedKeys.join(
147
+ ', '
148
+ )}) but ssm.enable is not true. Set ssm: { enable: true } in the app definition.`
149
+ );
150
+ }
151
+
152
+ for (const key of offloadedKeys) {
153
+ if (FRAMEWORK_ENV_BLOCKLIST.has(key)) {
154
+ errors.push(
155
+ `${key} is a framework-managed environment variable and cannot be offloaded to SSM.`
156
+ );
157
+ } else if (!ENV_NAME_PATTERN.test(key)) {
158
+ errors.push(
159
+ `'${key}' is not a valid environment variable name (expected ${ENV_NAME_PATTERN}); it cannot be offloaded to SSM.`
160
+ );
161
+ }
162
+ }
163
+
164
+ return { errors };
165
+ }
166
+
167
+ module.exports = {
168
+ getOffloadedKeys,
169
+ getParameterPrefix,
170
+ resolveParameterPrefix,
171
+ isSsmOffloadActive,
172
+ validateOffloadConfig,
173
+ FRAMEWORK_ENV_BLOCKLIST,
174
+ DEFAULT_PARAMETER_PREFIX,
175
+ };
@@ -0,0 +1,193 @@
1
+ const {
2
+ getOffloadedKeys,
3
+ getParameterPrefix,
4
+ isSsmOffloadActive,
5
+ validateOffloadConfig,
6
+ FRAMEWORK_ENV_BLOCKLIST,
7
+ } = require('./offload-utils');
8
+
9
+ describe('offload-utils', () => {
10
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
11
+
12
+ afterEach(() => {
13
+ if (originalSkipDiscovery === undefined) {
14
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
15
+ } else {
16
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
17
+ }
18
+ });
19
+
20
+ describe('getOffloadedKeys', () => {
21
+ it('returns empty array when nothing is marked', () => {
22
+ expect(getOffloadedKeys({})).toEqual([]);
23
+ expect(
24
+ getOffloadedKeys({ environment: { MY_VAR: true } })
25
+ ).toEqual([]);
26
+ });
27
+
28
+ it("collects environment keys marked 'ssm'", () => {
29
+ const keys = getOffloadedKeys({
30
+ environment: { B_VAR: 'ssm', A_VAR: 'ssm', PLAIN: true },
31
+ });
32
+ expect(keys).toEqual(['A_VAR', 'B_VAR']);
33
+ });
34
+
35
+ it('collects ssm.parameters keys', () => {
36
+ const keys = getOffloadedKeys({
37
+ ssm: {
38
+ enable: true,
39
+ parameters: { MY_SECRET: { type: 'SecureString' } },
40
+ },
41
+ });
42
+ expect(keys).toEqual(['MY_SECRET']);
43
+ });
44
+
45
+ it('unions and dedupes both sources, sorted', () => {
46
+ const keys = getOffloadedKeys({
47
+ environment: { SHARED: 'ssm', ENV_ONLY: 'ssm' },
48
+ ssm: {
49
+ enable: true,
50
+ parameters: {
51
+ SHARED: { type: 'SecureString' },
52
+ PARAM_ONLY: { type: 'String' },
53
+ },
54
+ },
55
+ });
56
+ expect(keys).toEqual(['ENV_ONLY', 'PARAM_ONLY', 'SHARED']);
57
+ });
58
+ });
59
+
60
+ describe('getParameterPrefix', () => {
61
+ it('defaults to the frigg-scoped serverless-variable path', () => {
62
+ expect(getParameterPrefix({})).toBe(
63
+ '/frigg/${self:service}/${self:provider.stage}'
64
+ );
65
+ });
66
+
67
+ it('honors ssm.parameterPrefix override', () => {
68
+ expect(
69
+ getParameterPrefix({ ssm: { parameterPrefix: '/custom/x' } })
70
+ ).toBe('/custom/x');
71
+ });
72
+
73
+ it('strips a trailing slash from the override', () => {
74
+ expect(
75
+ getParameterPrefix({ ssm: { parameterPrefix: '/custom/x/' } })
76
+ ).toBe('/custom/x');
77
+ });
78
+ });
79
+
80
+ describe('resolveParameterPrefix', () => {
81
+ const { resolveParameterPrefix } = require('./offload-utils');
82
+
83
+ it('resolves the default prefix with service and stage', () => {
84
+ expect(
85
+ resolveParameterPrefix({ name: 'my-app' }, 'prod')
86
+ ).toBe('/frigg/my-app/prod');
87
+ });
88
+
89
+ it('resolves serverless tokens inside a custom prefix', () => {
90
+ expect(
91
+ resolveParameterPrefix(
92
+ {
93
+ name: 'my-app',
94
+ ssm: {
95
+ parameterPrefix:
96
+ '/x/${self:service}/${self:provider.stage}',
97
+ },
98
+ },
99
+ 'dev'
100
+ )
101
+ ).toBe('/x/my-app/dev');
102
+ });
103
+
104
+ it('passes literal custom prefixes through', () => {
105
+ expect(
106
+ resolveParameterPrefix(
107
+ { name: 'my-app', ssm: { parameterPrefix: '/plain/path' } },
108
+ 'dev'
109
+ )
110
+ ).toBe('/plain/path');
111
+ });
112
+ });
113
+
114
+ describe('isSsmOffloadActive', () => {
115
+ const offloadApp = {
116
+ ssm: { enable: true },
117
+ environment: { MY_SECRET: 'ssm' },
118
+ };
119
+
120
+ it('is true when ssm enabled and offload set non-empty', () => {
121
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
122
+ expect(isSsmOffloadActive(offloadApp)).toBe(true);
123
+ });
124
+
125
+ it('is false without ssm.enable', () => {
126
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
127
+ expect(
128
+ isSsmOffloadActive({ environment: { MY_SECRET: 'ssm' } })
129
+ ).toBe(false);
130
+ });
131
+
132
+ it('is false when nothing is offloaded', () => {
133
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
134
+ expect(isSsmOffloadActive({ ssm: { enable: true } })).toBe(false);
135
+ });
136
+
137
+ it('is false in local mode (FRIGG_SKIP_AWS_DISCOVERY)', () => {
138
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
139
+ expect(isSsmOffloadActive(offloadApp)).toBe(false);
140
+ });
141
+ });
142
+
143
+ describe('validateOffloadConfig', () => {
144
+ it('passes for a clean offload config', () => {
145
+ const { errors } = validateOffloadConfig({
146
+ ssm: { enable: true },
147
+ environment: { MY_SECRET: 'ssm' },
148
+ });
149
+ expect(errors).toEqual([]);
150
+ });
151
+
152
+ it('errors when offload markers exist without ssm.enable', () => {
153
+ const { errors } = validateOffloadConfig({
154
+ environment: { MY_SECRET: 'ssm' },
155
+ });
156
+ expect(errors.join(' ')).toMatch(/ssm\.enable/);
157
+ });
158
+
159
+ it('errors on framework-managed (blocklisted) keys', () => {
160
+ const { errors } = validateOffloadConfig({
161
+ ssm: { enable: true },
162
+ environment: { DATABASE_URL: 'ssm' },
163
+ });
164
+ expect(errors.join(' ')).toMatch(/DATABASE_URL/);
165
+ });
166
+
167
+ it('errors on keys that are not valid env var names', () => {
168
+ const { errors } = validateOffloadConfig({
169
+ ssm: {
170
+ enable: true,
171
+ parameters: { 'api-keys/foo': { type: 'String' } },
172
+ },
173
+ });
174
+ expect(errors.join(' ')).toMatch(/api-keys\/foo/);
175
+ });
176
+ });
177
+
178
+ it('blocklist covers the framework-managed keys', () => {
179
+ for (const key of [
180
+ 'STAGE',
181
+ 'FRIGG_STACK',
182
+ 'KMS_KEY_ARN',
183
+ 'DATABASE_URL',
184
+ 'DATABASE_SECRET_ARN',
185
+ 'SECRET_ARN',
186
+ 'SSM_PARAMETER_PREFIX',
187
+ 'FRIGG_SSM_OFFLOADED_KEYS',
188
+ 'AWS_REGION',
189
+ ]) {
190
+ expect(FRAMEWORK_ENV_BLOCKLIST.has(key)).toBe(true);
191
+ }
192
+ });
193
+ });
@@ -9,6 +9,12 @@
9
9
  */
10
10
 
11
11
  const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
12
+ const {
13
+ getOffloadedKeys,
14
+ getParameterPrefix,
15
+ isSsmOffloadActive,
16
+ validateOffloadConfig,
17
+ } = require('./offload-utils');
12
18
 
13
19
  class SsmBuilder extends InfrastructureBuilder {
14
20
  constructor() {
@@ -41,6 +47,10 @@ class SsmBuilder extends InfrastructureBuilder {
41
47
  }
42
48
  }
43
49
 
50
+ for (const error of validateOffloadConfig(appDefinition).errors) {
51
+ result.addError(error);
52
+ }
53
+
44
54
  return result;
45
55
  }
46
56
 
@@ -55,20 +65,56 @@ class SsmBuilder extends InfrastructureBuilder {
55
65
  environment: {},
56
66
  };
57
67
 
58
- // Add IAM permissions for SSM Parameter Store
59
- result.iamStatements.push({
60
- Effect: 'Allow',
61
- Action: [
62
- 'ssm:GetParameter',
63
- 'ssm:GetParameters',
64
- 'ssm:GetParametersByPath',
65
- ],
66
- Resource: {
67
- 'Fn::Sub': 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*',
68
- },
69
- });
70
-
71
- console.log(' ✅ SSM Parameter Store IAM permissions added');
68
+ const ssmActions = [
69
+ 'ssm:GetParameter',
70
+ 'ssm:GetParameters',
71
+ 'ssm:GetParametersByPath',
72
+ ];
73
+
74
+ const offloadActive = isSsmOffloadActive(appDefinition);
75
+
76
+ // Broad read grant, unless the app opts into prefix-only access while
77
+ // offload is active.
78
+ if (!(appDefinition.ssm.restrictIamToPrefix === true && offloadActive)) {
79
+ result.iamStatements.push({
80
+ Effect: 'Allow',
81
+ Action: ssmActions,
82
+ Resource: {
83
+ 'Fn::Sub': 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*',
84
+ },
85
+ });
86
+ console.log(' ✅ SSM Parameter Store IAM permissions added');
87
+ }
88
+
89
+ if (offloadActive) {
90
+ const prefix = getParameterPrefix(appDefinition);
91
+ const offloadedKeys = getOffloadedKeys(appDefinition);
92
+
93
+ result.environment.SSM_PARAMETER_PREFIX = prefix;
94
+ result.environment.FRIGG_SSM_OFFLOADED_KEYS = offloadedKeys.join(',');
95
+
96
+ // Prefix-scoped read grant. Built as a plain serverless string (not
97
+ // Fn::Sub) because the prefix contains serverless variables like
98
+ // ${self:service} that Fn::Sub would reject as bad substitution keys.
99
+ result.iamStatements.push({
100
+ Effect: 'Allow',
101
+ Action: ssmActions,
102
+ Resource: `arn:aws:ssm:\${self:provider.region}:\${aws:accountId}:parameter${prefix}/*`,
103
+ });
104
+ console.log(
105
+ ` ✅ SSM offload enabled for ${offloadedKeys.length} variable(s) under ${prefix}`
106
+ );
107
+
108
+ if (appDefinition.ssm.kmsKeyArn) {
109
+ result.iamStatements.push({
110
+ Effect: 'Allow',
111
+ Action: ['kms:Decrypt'],
112
+ Resource: appDefinition.ssm.kmsKeyArn,
113
+ });
114
+ console.log(' ✅ KMS decrypt permission added for offloaded parameters');
115
+ }
116
+ }
117
+
72
118
  console.log(`[${this.name}] ✅ SSM configuration completed`);
73
119
 
74
120
  return result;
@@ -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,114 @@ 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('should add a prefix-scoped read statement while retaining the broad grant by default', async () => {
238
+ const appDefinition = {
239
+ ssm: { enable: true },
240
+ environment: { FOO: 'ssm' },
241
+ };
242
+
243
+ const result = await ssmBuilder.build(appDefinition, {});
244
+
245
+ const broad = result.iamStatements.find(
246
+ s => s.Resource && s.Resource['Fn::Sub'] === 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*'
247
+ );
248
+ expect(broad).toBeDefined();
249
+
250
+ const scoped = result.iamStatements.find(
251
+ s => s.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/frigg/${self:service}/${self:provider.stage}/*'
252
+ );
253
+ expect(scoped).toBeDefined();
254
+ expect(scoped.Action).toEqual([
255
+ 'ssm:GetParameter',
256
+ 'ssm:GetParameters',
257
+ 'ssm:GetParametersByPath',
258
+ ]);
259
+ });
260
+
261
+ it('should drop the broad grant when restrictIamToPrefix is set', async () => {
262
+ const appDefinition = {
263
+ ssm: { enable: true, restrictIamToPrefix: true },
264
+ environment: { FOO: 'ssm' },
265
+ };
266
+
267
+ const result = await ssmBuilder.build(appDefinition, {});
268
+
269
+ const broad = result.iamStatements.find(
270
+ s => s.Resource && s.Resource['Fn::Sub']
271
+ );
272
+ expect(broad).toBeUndefined();
273
+
274
+ const scoped = result.iamStatements.find(
275
+ s => s.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/frigg/${self:service}/${self:provider.stage}/*'
276
+ );
277
+ expect(scoped).toBeDefined();
278
+ });
279
+
280
+ it('should add kms:Decrypt when kmsKeyArn is set', async () => {
281
+ const appDefinition = {
282
+ ssm: {
283
+ enable: true,
284
+ kmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
285
+ },
286
+ environment: { FOO: 'ssm' },
287
+ };
288
+
289
+ const result = await ssmBuilder.build(appDefinition, {});
290
+
291
+ const decrypt = result.iamStatements.find(
292
+ s => Array.isArray(s.Action) && s.Action.includes('kms:Decrypt')
293
+ );
294
+ expect(decrypt).toEqual({
295
+ Effect: 'Allow',
296
+ Action: ['kms:Decrypt'],
297
+ Resource: 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
298
+ });
299
+ });
300
+
301
+ it('should honor a custom parameterPrefix', async () => {
302
+ const appDefinition = {
303
+ ssm: { enable: true, parameterPrefix: '/custom/${self:provider.stage}' },
304
+ environment: { FOO: 'ssm' },
305
+ };
306
+
307
+ const result = await ssmBuilder.build(appDefinition, {});
308
+
309
+ expect(result.environment.SSM_PARAMETER_PREFIX).toBe(
310
+ '/custom/${self:provider.stage}'
311
+ );
312
+ const scoped = result.iamStatements.find(
313
+ s => s.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/custom/${self:provider.stage}/*'
314
+ );
315
+ expect(scoped).toBeDefined();
316
+ });
173
317
  });
174
318
 
175
319
  describe('getDependencies()', () => {