create-aws-project 1.4.3 → 1.5.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.
package/README.md CHANGED
@@ -4,6 +4,8 @@ Create a new AWS project from scratch including CloudFront, API Gateway, Lambdas
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/create-aws-project.svg)](https://www.npmjs.com/package/create-aws-project)
6
6
 
7
+ ![AWS Architecture Diagram](./aws-architecture-diagram.svg)
8
+
7
9
  ## Quick Start
8
10
 
9
11
  ```bash
package/dist/aws/iam.d.ts CHANGED
@@ -19,12 +19,17 @@ export declare function createIAMClient(region?: string): IAMClient;
19
19
  */
20
20
  export declare function createCrossAccountIAMClient(region: string, targetAccountId: string): IAMClient;
21
21
  /**
22
- * Creates an IAM deployment user with path /deployment/
22
+ * Creates an IAM deployment user with path /deployment/, or adopts existing tagged user
23
23
  * @param client - IAMClient instance
24
24
  * @param userName - User name (format: {project}-{environment}-deploy)
25
- * @throws Error if user already exists or creation fails
25
+ * @throws Error if user exists but was not created by this tool
26
26
  */
27
- export declare function createDeploymentUser(client: IAMClient, userName: string): Promise<void>;
27
+ export declare function createOrAdoptDeploymentUser(client: IAMClient, userName: string): Promise<void>;
28
+ /**
29
+ * Legacy function for backward compatibility
30
+ * @deprecated Use createOrAdoptDeploymentUser instead
31
+ */
32
+ export declare const createDeploymentUser: typeof createOrAdoptDeploymentUser;
28
33
  /**
29
34
  * Creates a customer managed policy with minimal CDK deployment permissions
30
35
  * @param client - IAMClient instance
@@ -48,11 +53,19 @@ export interface AccessKeyCredentials {
48
53
  accessKeyId: string;
49
54
  secretAccessKey: string;
50
55
  }
56
+ /**
57
+ * Gets the count of access keys for an IAM user
58
+ * @param client - IAMClient instance
59
+ * @param userName - IAM user name
60
+ * @returns Number of access keys (active + inactive)
61
+ */
62
+ export declare function getAccessKeyCount(client: IAMClient, userName: string): Promise<number>;
51
63
  /**
52
64
  * Creates access key credentials for an IAM user
53
65
  * @param client - IAMClient instance
54
66
  * @param userName - IAM user name
55
67
  * @returns Access key ID and secret access key
68
+ * @throws Error if user already has 2 access keys (AWS maximum)
56
69
  * @note The secret access key is ONLY available at creation time
57
70
  */
58
71
  export declare function createAccessKey(client: IAMClient, userName: string): Promise<AccessKeyCredentials>;
package/dist/aws/iam.js CHANGED
@@ -1,4 +1,4 @@
1
- import { IAMClient, CreateUserCommand, GetUserCommand, CreatePolicyCommand, GetPolicyCommand, AttachUserPolicyCommand, CreateAccessKeyCommand, NoSuchEntityException, } from '@aws-sdk/client-iam';
1
+ import { IAMClient, CreateUserCommand, GetUserCommand, CreatePolicyCommand, GetPolicyCommand, AttachUserPolicyCommand, CreateAccessKeyCommand, ListUserTagsCommand, ListAccessKeysCommand, NoSuchEntityException, } from '@aws-sdk/client-iam';
2
2
  import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';
3
3
  import pc from 'picocolors';
4
4
  /**
@@ -73,16 +73,45 @@ async function policyExists(client, policyArn) {
73
73
  }
74
74
  }
75
75
  /**
76
- * Creates an IAM deployment user with path /deployment/
76
+ * Checks if an IAM user has a specific tag
77
+ * @param client - IAMClient instance
78
+ * @param userName - User name to check
79
+ * @param tagKey - Tag key to check for
80
+ * @param tagValue - Expected tag value
81
+ * @returns true if user has the tag with the specified value
82
+ */
83
+ async function userHasTag(client, userName, tagKey, tagValue) {
84
+ try {
85
+ const command = new ListUserTagsCommand({ UserName: userName });
86
+ const response = await client.send(command);
87
+ return response.Tags?.some((tag) => tag.Key === tagKey && tag.Value === tagValue) ?? false;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Creates an IAM deployment user with path /deployment/, or adopts existing tagged user
77
95
  * @param client - IAMClient instance
78
96
  * @param userName - User name (format: {project}-{environment}-deploy)
79
- * @throws Error if user already exists or creation fails
97
+ * @throws Error if user exists but was not created by this tool
80
98
  */
81
- export async function createDeploymentUser(client, userName) {
99
+ export async function createOrAdoptDeploymentUser(client, userName) {
82
100
  // Check if user already exists
83
101
  if (await userExists(client, userName)) {
84
- throw new Error(`IAM user "${userName}" already exists. Delete manually before retrying.`);
102
+ // Check if user was created by this tool (has ManagedBy tag)
103
+ const isManagedByUs = await userHasTag(client, userName, 'ManagedBy', 'create-aws-starter-kit');
104
+ if (isManagedByUs) {
105
+ // Adopt existing user - this is idempotent re-run
106
+ console.log(pc.yellow(` Adopting existing deployment user: ${userName}`));
107
+ return;
108
+ }
109
+ else {
110
+ // User exists but not managed by us - error
111
+ throw new Error(`IAM user "${userName}" exists but was not created by this tool. Delete it or use a different project name.`);
112
+ }
85
113
  }
114
+ // User doesn't exist - create it
86
115
  const command = new CreateUserCommand({
87
116
  UserName: userName,
88
117
  Path: '/deployment/',
@@ -94,6 +123,11 @@ export async function createDeploymentUser(client, userName) {
94
123
  await client.send(command);
95
124
  console.log(pc.green(` Created IAM user: ${userName}`));
96
125
  }
126
+ /**
127
+ * Legacy function for backward compatibility
128
+ * @deprecated Use createOrAdoptDeploymentUser instead
129
+ */
130
+ export const createDeploymentUser = createOrAdoptDeploymentUser;
97
131
  /**
98
132
  * CDK deployment policy document with minimal permissions
99
133
  * @param accountId - AWS account ID for resource ARNs
@@ -226,14 +260,36 @@ export async function attachPolicyToUser(client, userName, policyArn) {
226
260
  await client.send(command);
227
261
  console.log(pc.green(` Attached policy to user ${userName}`));
228
262
  }
263
+ /**
264
+ * Gets the count of access keys for an IAM user
265
+ * @param client - IAMClient instance
266
+ * @param userName - IAM user name
267
+ * @returns Number of access keys (active + inactive)
268
+ */
269
+ export async function getAccessKeyCount(client, userName) {
270
+ try {
271
+ const command = new ListAccessKeysCommand({ UserName: userName });
272
+ const response = await client.send(command);
273
+ return response.AccessKeyMetadata?.length ?? 0;
274
+ }
275
+ catch {
276
+ return 0;
277
+ }
278
+ }
229
279
  /**
230
280
  * Creates access key credentials for an IAM user
231
281
  * @param client - IAMClient instance
232
282
  * @param userName - IAM user name
233
283
  * @returns Access key ID and secret access key
284
+ * @throws Error if user already has 2 access keys (AWS maximum)
234
285
  * @note The secret access key is ONLY available at creation time
235
286
  */
236
287
  export async function createAccessKey(client, userName) {
288
+ // Check access key limit before attempting creation
289
+ const keyCount = await getAccessKeyCount(client, userName);
290
+ if (keyCount >= 2) {
291
+ throw new Error(`IAM user ${userName} already has 2 access keys (AWS maximum). Delete an existing key in AWS Console > IAM > Users > ${userName} > Security credentials before retrying.`);
292
+ }
237
293
  const command = new CreateAccessKeyCommand({
238
294
  UserName: userName,
239
295
  });
@@ -10,7 +10,7 @@ import prompts from 'prompts';
10
10
  import pc from 'picocolors';
11
11
  import { execSync } from 'node:child_process';
12
12
  import { requireProjectContext } from '../utils/project-context.js';
13
- import { createCrossAccountIAMClient, createDeploymentUserWithCredentials, } from '../aws/iam.js';
13
+ import { createCrossAccountIAMClient, createDeploymentUserWithCredentials, createAccessKey, } from '../aws/iam.js';
14
14
  import { createGitHubClient, setEnvironmentCredentials, parseGitHubUrl, } from '../github/secrets.js';
15
15
  const VALID_ENVIRONMENTS = ['dev', 'stage', 'prod'];
16
16
  /**
@@ -283,9 +283,24 @@ export async function runInitializeGitHub(args) {
283
283
  // Step 1: Create cross-account IAM client
284
284
  spinner.text = `Assuming role in ${env} account (${accountId})...`;
285
285
  const iamClient = createCrossAccountIAMClient(awsRegion, accountId);
286
- // Step 2: Create deployment user and credentials
287
- spinner.text = `Creating IAM deployment user...`;
288
- const credentials = await createDeploymentUserWithCredentials(iamClient, projectName, env, accountId);
286
+ // Step 2: Get deployment user credentials
287
+ // If setup-aws-envs already created the user, just create an access key.
288
+ // Otherwise fall back to full user creation (backward compat with older projects).
289
+ const existingUserName = config.deploymentUsers?.[env];
290
+ let userName;
291
+ let credentials;
292
+ if (existingUserName) {
293
+ spinner.text = `Using existing deployment user: ${existingUserName}`;
294
+ userName = existingUserName;
295
+ credentials = await createAccessKey(iamClient, userName);
296
+ spinner.succeed(`Created access key for ${userName}`);
297
+ }
298
+ else {
299
+ spinner.text = `Creating IAM deployment user...`;
300
+ const fullCredentials = await createDeploymentUserWithCredentials(iamClient, projectName, env, accountId);
301
+ userName = fullCredentials.userName;
302
+ credentials = fullCredentials;
303
+ }
289
304
  // Step 3: Configure GitHub
290
305
  const githubEnvName = GITHUB_ENV_NAMES[env];
291
306
  spinner.text = `Configuring GitHub Environment "${githubEnvName}"...`;
@@ -297,8 +312,13 @@ export async function runInitializeGitHub(args) {
297
312
  console.log(pc.green(`${env} environment setup complete!`));
298
313
  console.log('');
299
314
  console.log('Resources created:');
300
- console.log(` IAM User: ${pc.cyan(`arn:aws:iam::${accountId}:user/deployment/${credentials.userName}`)}`);
315
+ console.log(` Deployment User: ${pc.cyan(userName)}`);
301
316
  console.log(` GitHub Environment: ${pc.cyan(githubEnvName)}`);
317
+ // Add note if using existing user from setup-aws-envs
318
+ if (existingUserName) {
319
+ console.log('');
320
+ console.log(pc.dim('Note: Deployment user was created by setup-aws-envs. Access key created for GitHub.'));
321
+ }
302
322
  console.log('');
303
323
  console.log('View secrets at:');
304
324
  console.log(` ${pc.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/environments`)}`);
@@ -10,6 +10,7 @@ import pc from 'picocolors';
10
10
  import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { requireProjectContext } from '../utils/project-context.js';
12
12
  import { createOrganizationsClient, checkExistingOrganization, createOrganization, createAccount, waitForAccountCreation, } from '../aws/organizations.js';
13
+ import { createCrossAccountIAMClient, createOrAdoptDeploymentUser, createCDKDeploymentPolicy, attachPolicyToUser, } from '../aws/iam.js';
13
14
  /**
14
15
  * Environment names for account creation
15
16
  */
@@ -101,14 +102,18 @@ async function collectEmails(projectName) {
101
102
  };
102
103
  }
103
104
  /**
104
- * Updates the config file with account IDs
105
+ * Updates the config file with account IDs and optionally deployment user names
105
106
  * @param configPath Path to the config file
106
107
  * @param accounts Record of environment to account ID
108
+ * @param deploymentUsers Optional record of environment to deployment user name
107
109
  */
108
- function updateConfigAccounts(configPath, accounts) {
110
+ function updateConfig(configPath, accounts, deploymentUsers) {
109
111
  const content = readFileSync(configPath, 'utf-8');
110
112
  const config = JSON.parse(content);
111
113
  config.accounts = { ...config.accounts, ...accounts };
114
+ if (deploymentUsers) {
115
+ config.deploymentUsers = { ...config.deploymentUsers, ...deploymentUsers };
116
+ }
112
117
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
113
118
  }
114
119
  /**
@@ -174,14 +179,16 @@ export async function runSetupAwsEnvs(_args) {
174
179
  const { config, configPath } = context;
175
180
  // 2. Check if already configured (warn but don't abort - allows retry after partial failure)
176
181
  const existingAccounts = config.accounts ?? {};
182
+ const existingUsers = config.deploymentUsers ?? {};
177
183
  if (Object.keys(existingAccounts).length > 0) {
178
184
  console.log('');
179
185
  console.log(pc.yellow('Warning:') + ' AWS accounts already configured in this project:');
180
186
  for (const [env, id] of Object.entries(existingAccounts)) {
181
- console.log(` ${env}: ${id}`);
187
+ const userInfo = existingUsers[env] ? ` (user: ${existingUsers[env]})` : '';
188
+ console.log(` ${env}: ${id}${userInfo}`);
182
189
  }
183
190
  console.log('');
184
- console.log(pc.dim('Continuing will create additional accounts...'));
191
+ console.log(pc.dim('Continuing will skip existing accounts and create any missing ones...'));
185
192
  }
186
193
  // 3. Collect emails (all input before any AWS calls - per research pitfall #6)
187
194
  const emails = await collectEmails(config.projectName);
@@ -202,8 +209,13 @@ export async function runSetupAwsEnvs(_args) {
202
209
  spinner.succeed(`Using existing AWS Organization: ${orgId}`);
203
210
  }
204
211
  // Create accounts sequentially (per research - AWS rate limits require sequential)
205
- const accounts = {};
212
+ const accounts = { ...existingAccounts };
206
213
  for (const env of ENVIRONMENTS) {
214
+ // Skip if account already exists
215
+ if (existingAccounts[env]) {
216
+ spinner.succeed(`Using existing ${env} account: ${existingAccounts[env]}`);
217
+ continue;
218
+ }
207
219
  spinner.start(`Creating ${env} account (this may take several minutes)...`);
208
220
  const accountName = `${config.projectName}-${env}`;
209
221
  const { requestId } = await createAccount(client, emails[env], accountName);
@@ -211,19 +223,42 @@ export async function runSetupAwsEnvs(_args) {
211
223
  const result = await waitForAccountCreation(client, requestId);
212
224
  accounts[env] = result.accountId;
213
225
  // Save after EACH successful account (per research pitfall #3 - handle partial success)
214
- updateConfigAccounts(configPath, accounts);
226
+ updateConfig(configPath, accounts);
215
227
  spinner.succeed(`Created ${env} account: ${result.accountId}`);
216
228
  }
217
- // Final success
229
+ // Create IAM deployment users in each account
230
+ const deploymentUsers = { ...existingUsers };
231
+ for (const env of ENVIRONMENTS) {
232
+ // Skip if user already exists in config
233
+ if (existingUsers[env]) {
234
+ spinner.succeed(`Using existing deployment user: ${existingUsers[env]}`);
235
+ continue;
236
+ }
237
+ const accountId = accounts[env];
238
+ const userName = `${config.projectName}-${env}-deploy`;
239
+ const policyName = `${config.projectName}-${env}-cdk-deploy`;
240
+ spinner.start(`Creating deployment user in ${env} account...`);
241
+ const iamClient = createCrossAccountIAMClient(config.awsRegion, accountId);
242
+ await createOrAdoptDeploymentUser(iamClient, userName);
243
+ spinner.text = `Creating deployment policy for ${env}...`;
244
+ const policyArn = await createCDKDeploymentPolicy(iamClient, policyName, accountId);
245
+ await attachPolicyToUser(iamClient, userName, policyArn);
246
+ deploymentUsers[env] = userName;
247
+ // Save after EACH successful user creation
248
+ updateConfig(configPath, accounts, deploymentUsers);
249
+ spinner.succeed(`Created deployment user: ${userName}`);
250
+ }
251
+ // Final success with summary table
218
252
  console.log('');
219
253
  console.log(pc.green('AWS environment setup complete!'));
220
254
  console.log('');
221
- console.log('Account IDs saved to config:');
222
- for (const [env, id] of Object.entries(accounts)) {
223
- console.log(` ${env}: ${id}`);
255
+ console.log('Summary:');
256
+ console.log(` ${'Environment'.padEnd(14)}${'Account ID'.padEnd(16)}Deployment User`);
257
+ for (const env of ENVIRONMENTS) {
258
+ console.log(` ${env.padEnd(14)}${accounts[env].padEnd(16)}${deploymentUsers[env]}`);
224
259
  }
225
260
  console.log('');
226
- console.log(pc.bold('Next step:'));
261
+ console.log('Deployment users created. Next: initialize-github to create access keys and push to GitHub.');
227
262
  console.log(` ${pc.cyan('npx create-aws-project initialize-github dev')}`);
228
263
  }
229
264
  catch (error) {
@@ -19,6 +19,7 @@ export interface ProjectConfigMinimal {
19
19
  awsRegion: string;
20
20
  configVersion?: string;
21
21
  accounts?: Record<string, string>;
22
+ deploymentUsers?: Record<string, string>;
22
23
  }
23
24
  /**
24
25
  * Project context containing config path, project root, and parsed config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-aws-project",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "CLI tool to scaffold AWS projects",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -3,7 +3,6 @@ import * as s3 from 'aws-cdk-lib/aws-s3';
3
3
  import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
4
4
  import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
5
5
  import * as apigateway from 'aws-cdk-lib/aws-apigateway';
6
- import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless';
7
6
  import { Construct } from 'constructs';
8
7
 
9
8
  export interface StaticStackProps extends cdk.StackProps {
@@ -24,7 +23,6 @@ export class StaticStack extends cdk.Stack {
24
23
  public readonly bucket: s3.Bucket;
25
24
  public readonly api: apigateway.RestApi;
26
25
  public readonly distribution: cloudfront.Distribution;
27
- public readonly openSearchCollection: opensearchserverless.CfnCollection;
28
26
 
29
27
  constructor(scope: Construct, id: string, props: StaticStackProps) {
30
28
  super(scope, id, props);
@@ -205,94 +203,6 @@ export class StaticStack extends cdk.Stack {
205
203
  ],
206
204
  });
207
205
 
208
- // OpenSearch Serverless Collection
209
- const collectionName = `{{PROJECT_NAME}}-${environmentName}`;
210
-
211
- // Encryption policy (required for all collections)
212
- const encryptionPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OpenSearchEncryptionPolicy', {
213
- name: `${collectionName}-encryption`,
214
- type: 'encryption',
215
- policy: JSON.stringify({
216
- Rules: [
217
- {
218
- ResourceType: 'collection',
219
- Resource: [`collection/${collectionName}`],
220
- },
221
- ],
222
- AWSOwnedKey: true,
223
- }),
224
- });
225
-
226
- // Network policy - allows public access (adjust for production)
227
- const networkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OpenSearchNetworkPolicy', {
228
- name: `${collectionName}-network`,
229
- type: 'network',
230
- policy: JSON.stringify([
231
- {
232
- Rules: [
233
- {
234
- ResourceType: 'collection',
235
- Resource: [`collection/${collectionName}`],
236
- },
237
- {
238
- ResourceType: 'dashboard',
239
- Resource: [`collection/${collectionName}`],
240
- },
241
- ],
242
- AllowFromPublic: true,
243
- },
244
- ]),
245
- });
246
-
247
- // Data access policy - grants access to the collection
248
- const dataAccessPolicy = new opensearchserverless.CfnAccessPolicy(this, 'OpenSearchDataAccessPolicy', {
249
- name: `${collectionName}-access`,
250
- type: 'data',
251
- policy: JSON.stringify([
252
- {
253
- Rules: [
254
- {
255
- ResourceType: 'collection',
256
- Resource: [`collection/${collectionName}`],
257
- Permission: [
258
- 'aoss:CreateCollectionItems',
259
- 'aoss:DeleteCollectionItems',
260
- 'aoss:UpdateCollectionItems',
261
- 'aoss:DescribeCollectionItems',
262
- ],
263
- },
264
- {
265
- ResourceType: 'index',
266
- Resource: [`index/${collectionName}/*`],
267
- Permission: [
268
- 'aoss:CreateIndex',
269
- 'aoss:DeleteIndex',
270
- 'aoss:UpdateIndex',
271
- 'aoss:DescribeIndex',
272
- 'aoss:ReadDocument',
273
- 'aoss:WriteDocument',
274
- ],
275
- },
276
- ],
277
- Principal: [
278
- `arn:aws:iam::${this.account}:root`,
279
- ],
280
- },
281
- ]),
282
- });
283
-
284
- // Create the OpenSearch Serverless collection
285
- this.openSearchCollection = new opensearchserverless.CfnCollection(this, 'OpenSearchCollection', {
286
- name: collectionName,
287
- type: 'SEARCH', // SEARCH, TIMESERIES, or VECTORSEARCH
288
- description: `OpenSearch Serverless collection for {{PROJECT_NAME_TITLE}} - ${environmentName}`,
289
- });
290
-
291
- // Ensure policies are created before the collection
292
- this.openSearchCollection.addDependency(encryptionPolicy);
293
- this.openSearchCollection.addDependency(networkPolicy);
294
- this.openSearchCollection.addDependency(dataAccessPolicy);
295
-
296
206
  // Output important values
297
207
  new cdk.CfnOutput(this, 'BucketName', {
298
208
  value: this.bucket.bucketName,
@@ -339,23 +249,5 @@ export class StaticStack extends cdk.Stack {
339
249
  value: `https://${this.distribution.distributionDomainName}/api`,
340
250
  description: 'API URL via CloudFront',
341
251
  });
342
-
343
- new cdk.CfnOutput(this, 'OpenSearchCollectionEndpoint', {
344
- value: this.openSearchCollection.attrCollectionEndpoint,
345
- description: 'OpenSearch Serverless collection endpoint',
346
- exportName: `${environmentName}-opensearch-endpoint`,
347
- });
348
-
349
- new cdk.CfnOutput(this, 'OpenSearchDashboardEndpoint', {
350
- value: this.openSearchCollection.attrDashboardEndpoint,
351
- description: 'OpenSearch Serverless dashboard endpoint',
352
- exportName: `${environmentName}-opensearch-dashboard`,
353
- });
354
-
355
- new cdk.CfnOutput(this, 'OpenSearchCollectionArn', {
356
- value: this.openSearchCollection.attrArn,
357
- description: 'OpenSearch Serverless collection ARN',
358
- exportName: `${environmentName}-opensearch-arn`,
359
- });
360
252
  }
361
253
  }