@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
@@ -253,7 +253,7 @@ describe('VpcDiscovery', () => {
253
253
  expect(mockProvider.discoverVpc).toHaveBeenCalledWith(config);
254
254
  });
255
255
 
256
- it('should discover all VPC endpoints (S3, DynamoDB, KMS, Secrets Manager)', async () => {
256
+ it('should discover all VPC endpoints (S3, DynamoDB, KMS, Secrets Manager, SQS, SSM)', async () => {
257
257
  mockProvider.discoverVpc.mockResolvedValue({
258
258
  vpcId: 'vpc-123',
259
259
  vpcCidr: '10.0.0.0/16',
@@ -283,6 +283,22 @@ describe('VpcDiscovery', () => {
283
283
  ServiceName: 'com.amazonaws.us-east-1.secretsmanager',
284
284
  State: 'available',
285
285
  },
286
+ {
287
+ VpcEndpointId: 'vpce-sqs-def',
288
+ ServiceName: 'com.amazonaws.us-east-1.sqs',
289
+ State: 'available',
290
+ },
291
+ // ssmmessages must NOT be mistaken for the ssm endpoint
292
+ {
293
+ VpcEndpointId: 'vpce-ssmmessages-000',
294
+ ServiceName: 'com.amazonaws.us-east-1.ssmmessages',
295
+ State: 'available',
296
+ },
297
+ {
298
+ VpcEndpointId: 'vpce-ssm-ghi',
299
+ ServiceName: 'com.amazonaws.us-east-1.ssm',
300
+ State: 'available',
301
+ },
286
302
  ],
287
303
  });
288
304
 
@@ -292,6 +308,8 @@ describe('VpcDiscovery', () => {
292
308
  expect(result.dynamodbVpcEndpointId).toBe('vpce-ddb-456');
293
309
  expect(result.kmsVpcEndpointId).toBe('vpce-kms-789');
294
310
  expect(result.secretsManagerVpcEndpointId).toBe('vpce-sm-abc');
311
+ expect(result.sqsVpcEndpointId).toBe('vpce-sqs-def');
312
+ expect(result.ssmVpcEndpointId).toBe('vpce-ssm-ghi');
295
313
  });
296
314
 
297
315
  it('should handle partial VPC endpoint discovery', async () => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  const BaseResourceResolver = require('../shared/base-resolver');
15
15
  const { ResourceOwnership } = require('../shared/types');
16
+ const { isSsmOffloadActive } = require('../parameters/offload-utils');
16
17
 
17
18
  class VpcResourceResolver extends BaseResourceResolver {
18
19
  /**
@@ -381,7 +382,8 @@ class VpcResourceResolver extends BaseResourceResolver {
381
382
  dynamodb: { ownership: null, reason: 'VPC Endpoints disabled' },
382
383
  kms: { ownership: null, reason: 'VPC Endpoints disabled' },
383
384
  secretsManager: { ownership: null, reason: 'VPC Endpoints disabled' },
384
- sqs: { ownership: null, reason: 'VPC Endpoints disabled' }
385
+ sqs: { ownership: null, reason: 'VPC Endpoints disabled' },
386
+ ssm: { ownership: null, reason: 'VPC Endpoints disabled' }
385
387
  };
386
388
  }
387
389
 
@@ -389,6 +391,9 @@ class VpcResourceResolver extends BaseResourceResolver {
389
391
  const encryptionMethod = appDefinition.encryption?.fieldLevelEncryptionMethod;
390
392
  const needsKms = encryptionMethod === 'kms';
391
393
 
394
+ // SSM endpoint only needed when parameter offload is active
395
+ const needsSsm = isSsmOffloadActive(appDefinition);
396
+
392
397
  // DynamoDB endpoint only needed if using DynamoDB (not MongoDB or PostgreSQL)
393
398
  // Currently framework only supports MongoDB (via Prisma) and PostgreSQL (via Aurora)
394
399
  // If not using DynamoDB, skip the endpoint (CloudFormation will delete if it exists)
@@ -403,7 +408,10 @@ class VpcResourceResolver extends BaseResourceResolver {
403
408
  ? this._resolveEndpoint('FriggKMSVPCEndpoint', 'kms', userIntent, appDefinition, discovery)
404
409
  : { ownership: null, reason: 'KMS endpoint not needed (encryption method is not KMS)' },
405
410
  secretsManager: this._resolveEndpoint('FriggSecretsManagerVPCEndpoint', 'secretsManager', userIntent, appDefinition, discovery),
406
- sqs: this._resolveEndpoint('FriggSQSVPCEndpoint', 'sqs', userIntent, appDefinition, discovery)
411
+ sqs: this._resolveEndpoint('FriggSQSVPCEndpoint', 'sqs', userIntent, appDefinition, discovery),
412
+ ssm: needsSsm
413
+ ? this._resolveEndpoint('FriggSSMVPCEndpoint', 'ssm', userIntent, appDefinition, discovery)
414
+ : { ownership: null, reason: 'SSM endpoint not needed (SSM offload not active)' }
407
415
  };
408
416
 
409
417
  return endpoints;
@@ -421,7 +429,8 @@ class VpcResourceResolver extends BaseResourceResolver {
421
429
  'FriggDynamoDBVPCEndpoint': 'VPCEndpointDynamoDB',
422
430
  'FriggKMSVPCEndpoint': 'VPCEndpointKMS',
423
431
  'FriggSecretsManagerVPCEndpoint': 'VPCEndpointSecretsManager',
424
- 'FriggSQSVPCEndpoint': 'VPCEndpointSQS'
432
+ 'FriggSQSVPCEndpoint': 'VPCEndpointSQS',
433
+ 'FriggSSMVPCEndpoint': 'VPCEndpointSSM'
425
434
  };
426
435
  const oldLogicalId = oldLogicalIdMap[logicalId];
427
436
 
@@ -567,6 +567,46 @@ describe('VpcResourceResolver', () => {
567
567
  expect(decisions.kms.ownership).toBeNull();
568
568
  expect(decisions.secretsManager.ownership).toBeNull();
569
569
  expect(decisions.sqs.ownership).toBeNull();
570
+ expect(decisions.ssm.ownership).toBeNull();
571
+ });
572
+
573
+ describe('SSM endpoint', () => {
574
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
575
+
576
+ afterEach(() => {
577
+ if (originalSkipDiscovery === undefined) {
578
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
579
+ } else {
580
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
581
+ }
582
+ });
583
+
584
+ it('should create SSM endpoint when offload is active', () => {
585
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
586
+ const appDefinition = {
587
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
588
+ ssm: { enable: true },
589
+ environment: { FOO: 'ssm' }
590
+ };
591
+ const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
592
+
593
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
594
+
595
+ expect(decisions.ssm.ownership).toBe('stack');
596
+ });
597
+
598
+ it('should not create SSM endpoint when offload is not active', () => {
599
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
600
+ const appDefinition = {
601
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
602
+ ssm: { enable: true }
603
+ };
604
+ const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
605
+
606
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
607
+
608
+ expect(decisions.ssm.ownership).toBeNull();
609
+ });
570
610
  });
571
611
 
572
612
  it('should resolve to EXTERNAL with user-provided endpoint IDs', () => {
@@ -0,0 +1,190 @@
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
+ // INIT-phase preload shipped in @friggframework/core, present at this path in
20
+ // every skipEsbuild handler (which packages the full node_modules tree).
21
+ // LAMBDA_TASK_ROOT = /var/task.
22
+ const SSM_PRELOAD_PATH =
23
+ '/var/task/node_modules/@friggframework/core/core/ssm-preload.mjs';
24
+
25
+ // NODE_OPTIONS value that loads the INIT preload. Set ONLY on skipEsbuild
26
+ // functions (esbuild-bundled functions do not package the .mjs, and a missing
27
+ // --import target is a fatal Node startup error). No `${env:NODE_OPTIONS}`
28
+ // prefix: that source resolves the deploy host's shell at package time, which
29
+ // would leak an unrelated value into every Lambda.
30
+ const SSM_PRELOAD_NODE_OPTIONS = `--import file://${SSM_PRELOAD_PATH}`;
31
+
32
+ // Keys the framework itself sets (from discovered resources or the base
33
+ // template) or that the loader/bypass handlers depend on before SSM values
34
+ // are available. Never offloadable.
35
+ const FRAMEWORK_ENV_BLOCKLIST = new Set([
36
+ 'STAGE',
37
+ 'FRIGG_STACK',
38
+ 'FRIGG_STAGE',
39
+ 'FRIGG_REGION',
40
+ 'KMS_KEY_ARN',
41
+ 'AES_KEY',
42
+ 'AES_KEY_ID',
43
+ 'DATABASE_URL',
44
+ 'DATABASE_HOST',
45
+ 'DATABASE_PORT',
46
+ 'DATABASE_USER',
47
+ 'DATABASE_PASSWORD',
48
+ 'DATABASE_SECRET_ARN',
49
+ 'DB_TYPE',
50
+ 'MONGO_URI',
51
+ 'SECRET_ARN',
52
+ 'SSM_PARAMETER_PREFIX',
53
+ 'FRIGG_SSM_OFFLOADED_KEYS',
54
+ 'FRIGG_SSM_CACHE_TTL',
55
+ 'WORKER_FUNCTION_NAME',
56
+ // Reserved AWS Lambda runtime variables
57
+ '_HANDLER',
58
+ '_X_AMZN_TRACE_ID',
59
+ 'AWS_DEFAULT_REGION',
60
+ 'AWS_EXECUTION_ENV',
61
+ 'AWS_REGION',
62
+ 'AWS_LAMBDA_FUNCTION_NAME',
63
+ 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE',
64
+ 'AWS_LAMBDA_FUNCTION_VERSION',
65
+ 'AWS_LAMBDA_INITIALIZATION_TYPE',
66
+ 'AWS_LAMBDA_LOG_GROUP_NAME',
67
+ 'AWS_LAMBDA_LOG_STREAM_NAME',
68
+ 'AWS_ACCESS_KEY',
69
+ 'AWS_ACCESS_KEY_ID',
70
+ 'AWS_SECRET_ACCESS_KEY',
71
+ 'AWS_SESSION_TOKEN',
72
+ ]);
73
+
74
+ /**
75
+ * Union of keys marked for SSM offload, sorted for deterministic output.
76
+ * Sources: appDefinition.environment values === 'ssm' and
77
+ * appDefinition.ssm.parameters keys. Does NOT filter invalid/blocklisted
78
+ * keys — use validateOffloadConfig() to surface those as errors instead of
79
+ * silently dropping them.
80
+ *
81
+ * @param {Object} appDefinition
82
+ * @returns {string[]}
83
+ */
84
+ function getOffloadedKeys(appDefinition = {}) {
85
+ const keys = new Set();
86
+
87
+ for (const [key, value] of Object.entries(
88
+ appDefinition.environment || {}
89
+ )) {
90
+ if (value === 'ssm') keys.add(key);
91
+ }
92
+
93
+ for (const key of Object.keys(appDefinition.ssm?.parameters || {})) {
94
+ keys.add(key);
95
+ }
96
+
97
+ return [...keys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
98
+ }
99
+
100
+ /**
101
+ * Parameter path prefix under which offloaded values are stored. The default
102
+ * uses serverless variables resolved at package time and stays inside the
103
+ * *frigg* scope of the generated deployment IAM policies.
104
+ *
105
+ * @param {Object} appDefinition
106
+ * @returns {string}
107
+ */
108
+ function getParameterPrefix(appDefinition = {}) {
109
+ const prefix =
110
+ appDefinition.ssm?.parameterPrefix || DEFAULT_PARAMETER_PREFIX;
111
+ return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
112
+ }
113
+
114
+ /**
115
+ * Resolve the parameter prefix to a concrete path outside a serverless
116
+ * build (e.g. in the CLI), substituting the serverless variables the
117
+ * default prefix uses.
118
+ *
119
+ * @param {Object} appDefinition
120
+ * @param {string} stage
121
+ * @returns {string}
122
+ */
123
+ function resolveParameterPrefix(appDefinition = {}, stage) {
124
+ return getParameterPrefix(appDefinition)
125
+ .replaceAll('${self:service}', appDefinition.name)
126
+ .replaceAll('${self:provider.stage}', stage);
127
+ }
128
+
129
+ /**
130
+ * Whether the offload feature is active for this build: SSM enabled, at
131
+ * least one key marked, and not running in local mode.
132
+ *
133
+ * @param {Object} appDefinition
134
+ * @returns {boolean}
135
+ */
136
+ function isSsmOffloadActive(appDefinition = {}) {
137
+ if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') return false;
138
+ if (appDefinition.ssm?.enable !== true) return false;
139
+ return getOffloadedKeys(appDefinition).length > 0;
140
+ }
141
+
142
+ /**
143
+ * Validate the offload configuration. Returns errors (not warnings) for
144
+ * misconfigurations that would silently lose variables at runtime.
145
+ *
146
+ * @param {Object} appDefinition
147
+ * @returns {{errors: string[]}}
148
+ */
149
+ function validateOffloadConfig(appDefinition = {}) {
150
+ const errors = [];
151
+ const offloadedKeys = getOffloadedKeys(appDefinition);
152
+
153
+ if (offloadedKeys.length === 0) {
154
+ return { errors };
155
+ }
156
+
157
+ if (appDefinition.ssm?.enable !== true) {
158
+ errors.push(
159
+ `Environment keys are marked for SSM offload (${offloadedKeys.join(
160
+ ', '
161
+ )}) but ssm.enable is not true. Set ssm: { enable: true } in the app definition.`
162
+ );
163
+ }
164
+
165
+ for (const key of offloadedKeys) {
166
+ if (FRAMEWORK_ENV_BLOCKLIST.has(key)) {
167
+ errors.push(
168
+ `${key} is a framework-managed environment variable and cannot be offloaded to SSM.`
169
+ );
170
+ } else if (!ENV_NAME_PATTERN.test(key)) {
171
+ errors.push(
172
+ `'${key}' is not a valid environment variable name (expected ${ENV_NAME_PATTERN}); it cannot be offloaded to SSM.`
173
+ );
174
+ }
175
+ }
176
+
177
+ return { errors };
178
+ }
179
+
180
+ module.exports = {
181
+ getOffloadedKeys,
182
+ getParameterPrefix,
183
+ resolveParameterPrefix,
184
+ isSsmOffloadActive,
185
+ validateOffloadConfig,
186
+ FRAMEWORK_ENV_BLOCKLIST,
187
+ DEFAULT_PARAMETER_PREFIX,
188
+ SSM_PRELOAD_PATH,
189
+ SSM_PRELOAD_NODE_OPTIONS,
190
+ };
@@ -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,63 @@ 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
+ // The INIT-phase preload (NODE_OPTIONS=--import) that populates
97
+ // process.env before app modules load is set per-function on
98
+ // skipEsbuild handlers only — see infrastructure-composer.js. It
99
+ // cannot go here (provider.environment is global) because a missing
100
+ // --import target fatally aborts Node startup, and esbuild-bundled
101
+ // functions do not package the preload file.
102
+
103
+ // Prefix-scoped read grant. Built as a plain serverless string (not
104
+ // Fn::Sub) because the prefix contains serverless variables like
105
+ // ${self:service} that Fn::Sub would reject as bad substitution keys.
106
+ result.iamStatements.push({
107
+ Effect: 'Allow',
108
+ Action: ssmActions,
109
+ Resource: `arn:aws:ssm:\${self:provider.region}:\${aws:accountId}:parameter${prefix}/*`,
110
+ });
111
+ console.log(
112
+ ` ✅ SSM offload enabled for ${offloadedKeys.length} variable(s) under ${prefix}`
113
+ );
114
+
115
+ if (appDefinition.ssm.kmsKeyArn) {
116
+ result.iamStatements.push({
117
+ Effect: 'Allow',
118
+ Action: ['kms:Decrypt'],
119
+ Resource: appDefinition.ssm.kmsKeyArn,
120
+ });
121
+ console.log(' ✅ KMS decrypt permission added for offloaded parameters');
122
+ }
123
+ }
124
+
72
125
  console.log(`[${this.name}] ✅ SSM configuration completed`);
73
126
 
74
127
  return result;