@beesolve/aws-accounts 1.0.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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/accountCreation.js +135 -0
- package/dist/applyLogic.js +1203 -0
- package/dist/awsClientConfig.js +26 -0
- package/dist/awsConfig.js +1365 -0
- package/dist/cli.js +201 -0
- package/dist/commands/graveyard.js +46 -0
- package/dist/commands/regenerate.js +17 -0
- package/dist/commands/remote.js +925 -0
- package/dist/diff.js +1012 -0
- package/dist/error.js +66 -0
- package/dist/helpers.js +21 -0
- package/dist/lambda/handler.js +375 -0
- package/dist/lambdaClient.js +220 -0
- package/dist/logger.js +26 -0
- package/dist/operations.js +218 -0
- package/dist/remoteStateCache.js +38 -0
- package/dist/reservedOuDeletion.js +46 -0
- package/dist/scanLogic.js +456 -0
- package/dist/state.js +618 -0
- package/dist/tags.js +14 -0
- package/dist-lambda/handler.mjs +3558 -0
- package/dist-lambda/lambda.zip +0 -0
- package/package.json +59 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
import {
|
|
6
|
+
CreateBucketCommand,
|
|
7
|
+
PutBucketTaggingCommand
|
|
8
|
+
} from "@aws-sdk/client-s3";
|
|
9
|
+
import {
|
|
10
|
+
CreateRoleCommand,
|
|
11
|
+
GetRoleCommand,
|
|
12
|
+
PutRolePolicyCommand,
|
|
13
|
+
TagRoleCommand
|
|
14
|
+
} from "@aws-sdk/client-iam";
|
|
15
|
+
import {
|
|
16
|
+
CreateFunctionCommand,
|
|
17
|
+
GetFunctionCommand,
|
|
18
|
+
LambdaClient,
|
|
19
|
+
PutFunctionConcurrencyCommand,
|
|
20
|
+
ResourceNotFoundException,
|
|
21
|
+
TagResourceCommand,
|
|
22
|
+
UpdateFunctionCodeCommand,
|
|
23
|
+
UpdateFunctionConfigurationCommand
|
|
24
|
+
} from "@aws-sdk/client-lambda";
|
|
25
|
+
import { GetCallerIdentityCommand } from "@aws-sdk/client-sts";
|
|
26
|
+
import {
|
|
27
|
+
CreatePermissionSetCommand,
|
|
28
|
+
DescribePermissionSetCommand,
|
|
29
|
+
ListPermissionSetsCommand,
|
|
30
|
+
PutInlinePolicyToPermissionSetCommand,
|
|
31
|
+
TagResourceCommand as SsoTagResourceCommand,
|
|
32
|
+
UpdatePermissionSetCommand
|
|
33
|
+
} from "@aws-sdk/client-sso-admin";
|
|
34
|
+
import {
|
|
35
|
+
loadAwsConfigModelFromTsFile,
|
|
36
|
+
mapAwsConfigToState,
|
|
37
|
+
readAwsContextFromFile,
|
|
38
|
+
regenerateTypesFromState,
|
|
39
|
+
writeAwsConfigFromState
|
|
40
|
+
} from "../awsConfig.js";
|
|
41
|
+
import { buildAwsClientConfig } from "../awsClientConfig.js";
|
|
42
|
+
import { getStandardTags } from "../tags.js";
|
|
43
|
+
import { diffStates } from "../diff.js";
|
|
44
|
+
import { invokeLambda } from "../lambdaClient.js";
|
|
45
|
+
import {
|
|
46
|
+
isCacheFresh,
|
|
47
|
+
readStateCache,
|
|
48
|
+
writeStateCache
|
|
49
|
+
} from "../remoteStateCache.js";
|
|
50
|
+
import { applyReservedOuDeletionGuard } from "../reservedOuDeletion.js";
|
|
51
|
+
import { validateState } from "../state.js";
|
|
52
|
+
import { assertUnreachable, delay } from "../helpers.js";
|
|
53
|
+
import { iam } from "@beesolve/iam-policy-ts";
|
|
54
|
+
const remoteCommandSchema = v.object({
|
|
55
|
+
subcommand: v.picklist(["bootstrap", "scan", "init", "plan", "apply", "upgrade"]),
|
|
56
|
+
profile: v.optional(v.string()),
|
|
57
|
+
region: v.optional(v.string()),
|
|
58
|
+
flags: v.object({
|
|
59
|
+
yes: v.boolean(),
|
|
60
|
+
refresh: v.boolean(),
|
|
61
|
+
allowDestructive: v.boolean(),
|
|
62
|
+
ignoreUnsupported: v.boolean()
|
|
63
|
+
})
|
|
64
|
+
});
|
|
65
|
+
const contextFilePath = "aws.context.json";
|
|
66
|
+
const configFilePath = "aws.config.ts";
|
|
67
|
+
const typesFilePath = "aws.config.types.ts";
|
|
68
|
+
const cachePath = ".remote-state-cache.json";
|
|
69
|
+
const lambdaZipPath = "dist-lambda/lambda.zip";
|
|
70
|
+
const lambdaRoleName = "beesolve-aws-accounts-lambda-role";
|
|
71
|
+
const lambdaFunctionName = "beesolve-aws-accounts";
|
|
72
|
+
async function runRemoteBootstrap(input) {
|
|
73
|
+
const lambdaZip = await readLambdaZip();
|
|
74
|
+
const callerIdentity = await input.stsClient.send(new GetCallerIdentityCommand({}));
|
|
75
|
+
const accountId = callerIdentity.Account;
|
|
76
|
+
if (accountId == null) {
|
|
77
|
+
throw new Error("Could not determine AWS account ID from STS.");
|
|
78
|
+
}
|
|
79
|
+
const resolvedRegion = input.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1";
|
|
80
|
+
const bucketName = `beesolve-aws-accounts-state-${accountId}-${resolvedRegion}`;
|
|
81
|
+
input.logger.log(`Account: ${accountId}`);
|
|
82
|
+
input.logger.log(`Region: ${resolvedRegion}`);
|
|
83
|
+
input.logger.log(`Bucket: ${bucketName}`);
|
|
84
|
+
try {
|
|
85
|
+
await input.s3Client.send(new CreateBucketCommand({
|
|
86
|
+
Bucket: bucketName,
|
|
87
|
+
CreateBucketConfiguration: resolvedRegion !== "us-east-1" ? {
|
|
88
|
+
LocationConstraint: resolvedRegion
|
|
89
|
+
} : void 0
|
|
90
|
+
}));
|
|
91
|
+
input.logger.log(`Created S3 bucket: ${bucketName}`);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const s3Error = error;
|
|
94
|
+
if (s3Error.name === "BucketAlreadyOwnedByYou" || s3Error.name === "BucketAlreadyExists") {
|
|
95
|
+
input.logger.log(`S3 bucket already exists: ${bucketName}`);
|
|
96
|
+
} else {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await input.s3Client.send(new PutBucketTaggingCommand({
|
|
101
|
+
Bucket: bucketName,
|
|
102
|
+
Tagging: {
|
|
103
|
+
TagSet: getStandardTags("state-storage")
|
|
104
|
+
}
|
|
105
|
+
}));
|
|
106
|
+
const { roleArn } = await ensureIamRole({
|
|
107
|
+
iamClient: input.iamClient,
|
|
108
|
+
bucketName,
|
|
109
|
+
logger: input.logger
|
|
110
|
+
});
|
|
111
|
+
const lambdaArn = await ensureLambdaFunction({
|
|
112
|
+
lambdaClient: input.lambdaClient,
|
|
113
|
+
roleArn,
|
|
114
|
+
lambdaZip,
|
|
115
|
+
bucketName,
|
|
116
|
+
resolvedRegion,
|
|
117
|
+
logger: input.logger
|
|
118
|
+
});
|
|
119
|
+
const context = await readAwsContextFromFile(contextFilePath);
|
|
120
|
+
const deployment = {
|
|
121
|
+
profile: input.profile ?? "",
|
|
122
|
+
region: resolvedRegion,
|
|
123
|
+
lambdaArn,
|
|
124
|
+
stateBucketName: bucketName,
|
|
125
|
+
stateCacheTtlSeconds: 300
|
|
126
|
+
};
|
|
127
|
+
const updatedContext = {
|
|
128
|
+
...context,
|
|
129
|
+
deployment
|
|
130
|
+
};
|
|
131
|
+
const ordered = {
|
|
132
|
+
version: updatedContext.version,
|
|
133
|
+
generatedAt: updatedContext.generatedAt,
|
|
134
|
+
organization: updatedContext.organization,
|
|
135
|
+
identityCenter: updatedContext.identityCenter,
|
|
136
|
+
deployment: updatedContext.deployment
|
|
137
|
+
};
|
|
138
|
+
await writeFile(contextFilePath, `${JSON.stringify(ordered, null, 2)}
|
|
139
|
+
`, "utf8");
|
|
140
|
+
const instanceArn = updatedContext.identityCenter?.instanceArn;
|
|
141
|
+
if (instanceArn != null && instanceArn !== "") {
|
|
142
|
+
await ensureOrganizationManagementPermissionSet({
|
|
143
|
+
ssoAdminClient: input.ssoAdminClient,
|
|
144
|
+
instanceArn,
|
|
145
|
+
tags: getStandardTags("organization-management"),
|
|
146
|
+
logger: input.logger
|
|
147
|
+
}).catch((error) => {
|
|
148
|
+
input.logger.log(`Error creating OrganizationManagement permission set: ${error instanceof Error ? error.message : String(error)}`);
|
|
149
|
+
});
|
|
150
|
+
await ensureOrganizationRemoteManagementPermissionSet({
|
|
151
|
+
ssoAdminClient: input.ssoAdminClient,
|
|
152
|
+
instanceArn,
|
|
153
|
+
lambdaArn,
|
|
154
|
+
tags: getStandardTags("remote-invocation"),
|
|
155
|
+
logger: input.logger
|
|
156
|
+
}).catch((error) => {
|
|
157
|
+
input.logger.log(`Error creating OrganizationRemoteManagement permission set: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (instanceArn == null || instanceArn === "") {
|
|
161
|
+
input.logger.log("IAM Identity Center not configured, skipping permission set creation.");
|
|
162
|
+
}
|
|
163
|
+
input.logger.log("");
|
|
164
|
+
input.logger.log("Bootstrap complete.");
|
|
165
|
+
input.logger.log(` Lambda ARN: ${lambdaArn}`);
|
|
166
|
+
input.logger.log(` State bucket: ${bucketName}`);
|
|
167
|
+
}
|
|
168
|
+
async function ensureIamRole(props) {
|
|
169
|
+
const trustPolicy = JSON.stringify({
|
|
170
|
+
Version: "2012-10-17",
|
|
171
|
+
Statement: [
|
|
172
|
+
{
|
|
173
|
+
Effect: "Allow",
|
|
174
|
+
Principal: { Service: "lambda.amazonaws.com" },
|
|
175
|
+
Action: iam.sts("AssumeRole")
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
});
|
|
179
|
+
const { roleArn } = await getOrCreateIamRole({
|
|
180
|
+
iamClient: props.iamClient,
|
|
181
|
+
trustPolicy,
|
|
182
|
+
logger: props.logger
|
|
183
|
+
});
|
|
184
|
+
const inlinePolicy = JSON.stringify({
|
|
185
|
+
Version: "2012-10-17",
|
|
186
|
+
Statement: [
|
|
187
|
+
{
|
|
188
|
+
Effect: "Allow",
|
|
189
|
+
Action: iam.organizations("*"),
|
|
190
|
+
Resource: "*"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
Effect: "Allow",
|
|
194
|
+
Action: [iam.sso("*"), iam.identitystore("*")],
|
|
195
|
+
Resource: "*"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
Effect: "Allow",
|
|
199
|
+
Action: [iam.s3("GetObject"), iam.s3("PutObject"), iam.s3("ListBucket")],
|
|
200
|
+
Resource: [
|
|
201
|
+
`arn:aws:s3:::${props.bucketName}`,
|
|
202
|
+
`arn:aws:s3:::${props.bucketName}/*`
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
Effect: "Allow",
|
|
207
|
+
Action: [
|
|
208
|
+
iam.logs("CreateLogGroup"),
|
|
209
|
+
iam.logs("CreateLogStream"),
|
|
210
|
+
iam.logs("PutLogEvents")
|
|
211
|
+
],
|
|
212
|
+
Resource: "arn:aws:logs:*:*:*"
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
Effect: "Allow",
|
|
216
|
+
Action: [iam.account("PutAccountName")],
|
|
217
|
+
Resource: "*"
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
});
|
|
221
|
+
await props.iamClient.send(
|
|
222
|
+
new PutRolePolicyCommand({
|
|
223
|
+
RoleName: lambdaRoleName,
|
|
224
|
+
PolicyName: "beesolve-aws-accounts-execution-policy",
|
|
225
|
+
PolicyDocument: inlinePolicy
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
return { roleArn };
|
|
229
|
+
}
|
|
230
|
+
async function getOrCreateIamRole(props) {
|
|
231
|
+
try {
|
|
232
|
+
const getRole = await props.iamClient.send(
|
|
233
|
+
new GetRoleCommand({ RoleName: lambdaRoleName })
|
|
234
|
+
);
|
|
235
|
+
const roleArn2 = getRole.Role?.Arn ?? "";
|
|
236
|
+
if (roleArn2 === "") {
|
|
237
|
+
throw new Error("IAM role exists but ARN is empty.");
|
|
238
|
+
}
|
|
239
|
+
props.logger.log(`IAM role already exists: ${lambdaRoleName}`);
|
|
240
|
+
await props.iamClient.send(
|
|
241
|
+
new TagRoleCommand({
|
|
242
|
+
RoleName: lambdaRoleName,
|
|
243
|
+
Tags: getStandardTags("execution-role")
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
return { roleArn: roleArn2 };
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error.name !== "NoSuchEntityException") {
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const createRole = await props.iamClient.send(
|
|
253
|
+
new CreateRoleCommand({
|
|
254
|
+
RoleName: lambdaRoleName,
|
|
255
|
+
AssumeRolePolicyDocument: props.trustPolicy,
|
|
256
|
+
Description: "Execution role for beesolve-aws-accounts Lambda",
|
|
257
|
+
Tags: getStandardTags("execution-role")
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
const roleArn = createRole.Role?.Arn ?? "";
|
|
261
|
+
if (roleArn === "") {
|
|
262
|
+
throw new Error("Failed to create IAM role: ARN is empty.");
|
|
263
|
+
}
|
|
264
|
+
props.logger.log(`Created IAM role: ${lambdaRoleName}`);
|
|
265
|
+
return { roleArn };
|
|
266
|
+
}
|
|
267
|
+
async function ensureLambdaFunction(props) {
|
|
268
|
+
try {
|
|
269
|
+
const getFunction = await props.lambdaClient.send(
|
|
270
|
+
new GetFunctionCommand({ FunctionName: lambdaFunctionName })
|
|
271
|
+
);
|
|
272
|
+
const existingArn = getFunction.Configuration?.FunctionArn ?? "";
|
|
273
|
+
if (existingArn === "") {
|
|
274
|
+
throw new Error("Lambda function exists but ARN is empty.");
|
|
275
|
+
}
|
|
276
|
+
await props.lambdaClient.send(
|
|
277
|
+
new UpdateFunctionCodeCommand({
|
|
278
|
+
FunctionName: lambdaFunctionName,
|
|
279
|
+
ZipFile: props.lambdaZip
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
await props.lambdaClient.send(
|
|
283
|
+
new UpdateFunctionConfigurationCommand({
|
|
284
|
+
FunctionName: lambdaFunctionName,
|
|
285
|
+
Environment: {
|
|
286
|
+
Variables: {
|
|
287
|
+
STATE_BUCKET_NAME: props.bucketName
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
);
|
|
292
|
+
await props.lambdaClient.send(
|
|
293
|
+
new TagResourceCommand({
|
|
294
|
+
Resource: existingArn,
|
|
295
|
+
Tags: Object.fromEntries(getStandardTags("remote-execution").map((t) => [t.Key, t.Value]))
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
props.logger.log(`Updated Lambda function code: ${lambdaFunctionName}`);
|
|
299
|
+
return existingArn;
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof ResourceNotFoundException) {
|
|
302
|
+
const lambdaArn = await createLambdaFunctionWithRetry({
|
|
303
|
+
lambdaClient: props.lambdaClient,
|
|
304
|
+
roleArn: props.roleArn,
|
|
305
|
+
lambdaZip: props.lambdaZip,
|
|
306
|
+
bucketName: props.bucketName,
|
|
307
|
+
logger: props.logger
|
|
308
|
+
});
|
|
309
|
+
await props.lambdaClient.send(
|
|
310
|
+
new PutFunctionConcurrencyCommand({
|
|
311
|
+
FunctionName: lambdaFunctionName,
|
|
312
|
+
ReservedConcurrentExecutions: 1
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
props.logger.log(`Created Lambda function: ${lambdaFunctionName}`);
|
|
316
|
+
props.logger.log(`Set reserved concurrency to 1`);
|
|
317
|
+
return lambdaArn;
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const IAM_PROPAGATION_MAX_ATTEMPTS = 10;
|
|
323
|
+
const IAM_PROPAGATION_RETRY_INTERVAL_MS = 2e3;
|
|
324
|
+
async function createLambdaFunctionWithRetry(props) {
|
|
325
|
+
for (let attempt = 1; attempt <= IAM_PROPAGATION_MAX_ATTEMPTS; attempt++) {
|
|
326
|
+
try {
|
|
327
|
+
const createResult = await props.lambdaClient.send(
|
|
328
|
+
new CreateFunctionCommand({
|
|
329
|
+
FunctionName: lambdaFunctionName,
|
|
330
|
+
Runtime: "nodejs24.x",
|
|
331
|
+
Handler: "handler.handler",
|
|
332
|
+
Role: props.roleArn,
|
|
333
|
+
Code: { ZipFile: props.lambdaZip },
|
|
334
|
+
Timeout: 900,
|
|
335
|
+
MemorySize: 512,
|
|
336
|
+
PackageType: "Zip",
|
|
337
|
+
Architectures: ["arm64"],
|
|
338
|
+
Environment: {
|
|
339
|
+
Variables: {
|
|
340
|
+
STATE_BUCKET_NAME: props.bucketName
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
Tags: Object.fromEntries(getStandardTags("remote-execution").map((t) => [t.Key, t.Value]))
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
const lambdaArn = createResult.FunctionArn ?? "";
|
|
347
|
+
if (lambdaArn === "") {
|
|
348
|
+
throw new Error("Failed to create Lambda function: ARN is empty.");
|
|
349
|
+
}
|
|
350
|
+
return lambdaArn;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const isRoleNotReady = error.name === "InvalidParameterValueException";
|
|
353
|
+
if (!isRoleNotReady || attempt === IAM_PROPAGATION_MAX_ATTEMPTS) {
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
props.logger.log(`Waiting for IAM role to propagate (attempt ${attempt}/${IAM_PROPAGATION_MAX_ATTEMPTS})...`);
|
|
357
|
+
await delay(IAM_PROPAGATION_RETRY_INTERVAL_MS);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
throw new Error("Unreachable: retry loop exhausted without throwing.");
|
|
361
|
+
}
|
|
362
|
+
async function runRemoteScan(input) {
|
|
363
|
+
const deployment = await readDeploymentFromContext();
|
|
364
|
+
const clientConfig = buildAwsClientConfig({
|
|
365
|
+
profile: input.profile ?? (deployment.profile || void 0),
|
|
366
|
+
region: input.region ?? (deployment.region || void 0)
|
|
367
|
+
});
|
|
368
|
+
const lambdaClient = new LambdaClient(clientConfig);
|
|
369
|
+
input.logger.log("Invoking remote scan...");
|
|
370
|
+
const result = await invokeLambda({
|
|
371
|
+
lambdaClient,
|
|
372
|
+
lambdaArn: deployment.lambdaArn,
|
|
373
|
+
payload: { action: "scan" }
|
|
374
|
+
});
|
|
375
|
+
if (!result.ok) {
|
|
376
|
+
throw new Error(formatLambdaError(result.error));
|
|
377
|
+
}
|
|
378
|
+
const response = result.response;
|
|
379
|
+
if (!("action" in response) || response.action !== "scan") {
|
|
380
|
+
throw new Error("Unexpected response from Lambda scan action.");
|
|
381
|
+
}
|
|
382
|
+
input.logger.log("Scan complete.");
|
|
383
|
+
input.logger.log(` Organizational Units: ${response.summary.organizationalUnits}`);
|
|
384
|
+
input.logger.log(` Accounts: ${response.summary.accounts}`);
|
|
385
|
+
input.logger.log(` Users: ${response.summary.users}`);
|
|
386
|
+
input.logger.log(` Groups: ${response.summary.groups}`);
|
|
387
|
+
input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
|
|
388
|
+
input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
|
|
389
|
+
await writeStateCache(cachePath, response.state);
|
|
390
|
+
input.logger.log("State cache updated.");
|
|
391
|
+
}
|
|
392
|
+
const statePath = "state.json";
|
|
393
|
+
async function runRemoteInit(input) {
|
|
394
|
+
const deployment = await readDeploymentFromContext();
|
|
395
|
+
input.logger.log("Invoking remote scan...");
|
|
396
|
+
const result = await invokeLambda({
|
|
397
|
+
lambdaClient: input.lambdaClient,
|
|
398
|
+
lambdaArn: deployment.lambdaArn,
|
|
399
|
+
payload: { action: "scan" }
|
|
400
|
+
});
|
|
401
|
+
if (!result.ok) {
|
|
402
|
+
throw new Error(formatLambdaError(result.error));
|
|
403
|
+
}
|
|
404
|
+
const response = result.response;
|
|
405
|
+
if (!("action" in response) || response.action !== "scan") {
|
|
406
|
+
throw new Error("Unexpected response from Lambda scan action.");
|
|
407
|
+
}
|
|
408
|
+
input.logger.log("Scan complete.");
|
|
409
|
+
input.logger.log(` Organizational Units: ${response.summary.organizationalUnits}`);
|
|
410
|
+
input.logger.log(` Accounts: ${response.summary.accounts}`);
|
|
411
|
+
input.logger.log(` Users: ${response.summary.users}`);
|
|
412
|
+
input.logger.log(` Groups: ${response.summary.groups}`);
|
|
413
|
+
input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
|
|
414
|
+
input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
|
|
415
|
+
await Promise.all([
|
|
416
|
+
writeFile(statePath, `${JSON.stringify(response.state, null, 2)}
|
|
417
|
+
`, "utf8"),
|
|
418
|
+
writeStateCache(cachePath, response.state)
|
|
419
|
+
]);
|
|
420
|
+
input.logger.log("State written to state.json and cache updated.");
|
|
421
|
+
const configWriteResult = await writeAwsConfigFromState({
|
|
422
|
+
statePath,
|
|
423
|
+
contextPath: contextFilePath,
|
|
424
|
+
configPath: configFilePath,
|
|
425
|
+
typesPath: typesFilePath,
|
|
426
|
+
logger: input.logger,
|
|
427
|
+
overwriteConfirmation: input.overwriteConfirmation
|
|
428
|
+
});
|
|
429
|
+
const writtenFiles = configWriteResult.files.filter((f) => f.status === "written");
|
|
430
|
+
if (writtenFiles.length > 0) {
|
|
431
|
+
input.logger.log("");
|
|
432
|
+
input.logger.log("Init complete.");
|
|
433
|
+
for (const file of writtenFiles) {
|
|
434
|
+
input.logger.log(` ${file.path}: ${file.status}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function runRemotePlan(input) {
|
|
439
|
+
const deployment = await readDeploymentFromContext();
|
|
440
|
+
const currentState = await fetchCurrentState({
|
|
441
|
+
input,
|
|
442
|
+
deployment
|
|
443
|
+
});
|
|
444
|
+
const [context, config] = await Promise.all([
|
|
445
|
+
readAwsContextFromFile(contextFilePath),
|
|
446
|
+
loadAwsConfigModelFromTsFile({
|
|
447
|
+
configPath: configFilePath,
|
|
448
|
+
typesPath: typesFilePath
|
|
449
|
+
})
|
|
450
|
+
]);
|
|
451
|
+
const desiredState = mapAwsConfigToState({
|
|
452
|
+
config,
|
|
453
|
+
currentState,
|
|
454
|
+
context
|
|
455
|
+
});
|
|
456
|
+
const plan = applyReservedOuDeletionGuard({
|
|
457
|
+
plan: diffStates({
|
|
458
|
+
current: currentState,
|
|
459
|
+
next: desiredState
|
|
460
|
+
}),
|
|
461
|
+
context
|
|
462
|
+
});
|
|
463
|
+
displayPlan({ plan, logger: input.logger });
|
|
464
|
+
}
|
|
465
|
+
async function runRemoteApply(input) {
|
|
466
|
+
const deployment = await readDeploymentFromContext();
|
|
467
|
+
const currentState = await fetchCurrentState({
|
|
468
|
+
input,
|
|
469
|
+
deployment
|
|
470
|
+
});
|
|
471
|
+
const [context, config] = await Promise.all([
|
|
472
|
+
readAwsContextFromFile(contextFilePath),
|
|
473
|
+
loadAwsConfigModelFromTsFile({
|
|
474
|
+
configPath: configFilePath,
|
|
475
|
+
typesPath: typesFilePath
|
|
476
|
+
})
|
|
477
|
+
]);
|
|
478
|
+
const desiredState = mapAwsConfigToState({
|
|
479
|
+
config,
|
|
480
|
+
currentState,
|
|
481
|
+
context
|
|
482
|
+
});
|
|
483
|
+
const plan = applyReservedOuDeletionGuard({
|
|
484
|
+
plan: diffStates({
|
|
485
|
+
current: currentState,
|
|
486
|
+
next: desiredState
|
|
487
|
+
}),
|
|
488
|
+
context
|
|
489
|
+
});
|
|
490
|
+
if (plan.operations.length === 0) {
|
|
491
|
+
input.logger.log("No changes.");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
displayPlan({ plan, logger: input.logger });
|
|
495
|
+
if (!input.flags.yes) {
|
|
496
|
+
if (process.stdin.isTTY !== true) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
"Refusing to apply changes in non-interactive mode without --yes."
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const readlineInterface = createInterface({
|
|
502
|
+
input: process.stdin,
|
|
503
|
+
output: process.stdout
|
|
504
|
+
});
|
|
505
|
+
try {
|
|
506
|
+
const answer = await readlineInterface.question(
|
|
507
|
+
"Proceed with applying these changes? [y/N] "
|
|
508
|
+
);
|
|
509
|
+
const normalized = answer.trim().toLowerCase();
|
|
510
|
+
if (normalized !== "y" && normalized !== "yes") {
|
|
511
|
+
input.logger.log("Apply cancelled.");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
} finally {
|
|
515
|
+
readlineInterface.close();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const clientConfig = buildAwsClientConfig({
|
|
519
|
+
profile: input.profile ?? (deployment.profile || void 0),
|
|
520
|
+
region: input.region ?? (deployment.region || void 0)
|
|
521
|
+
});
|
|
522
|
+
const lambdaClient = new LambdaClient(clientConfig);
|
|
523
|
+
input.logger.log("Applying changes remotely...");
|
|
524
|
+
const result = await invokeLambda({
|
|
525
|
+
lambdaClient,
|
|
526
|
+
lambdaArn: deployment.lambdaArn,
|
|
527
|
+
payload: {
|
|
528
|
+
action: "apply",
|
|
529
|
+
operations: plan.operations,
|
|
530
|
+
allowDestructive: input.flags.allowDestructive
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
if (!result.ok) {
|
|
534
|
+
const error = result.error;
|
|
535
|
+
if (error.kind === "concurrencyConflict") {
|
|
536
|
+
input.logger.log("Another apply is in progress. Retry later.");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (error.kind === "operationFailed") {
|
|
540
|
+
input.logger.log(
|
|
541
|
+
`Apply failed at operation ${error.failedOperation + 1} of ${error.totalOperations}: ${error.error}`
|
|
542
|
+
);
|
|
543
|
+
await writeStateCache(cachePath, error.partialState);
|
|
544
|
+
input.logger.log("State cache updated with partial state.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
throw new Error(formatLambdaError(error));
|
|
548
|
+
}
|
|
549
|
+
const response = result.response;
|
|
550
|
+
if (!("action" in response) || response.action !== "apply") {
|
|
551
|
+
throw new Error("Unexpected response from Lambda apply action.");
|
|
552
|
+
}
|
|
553
|
+
input.logger.log(`Applied ${response.operationsCompleted} operation(s).`);
|
|
554
|
+
await writeStateCache(cachePath, response.state);
|
|
555
|
+
input.logger.log("State cache updated.");
|
|
556
|
+
await regenerateTypesFromState({
|
|
557
|
+
state: response.state,
|
|
558
|
+
contextPath: contextFilePath,
|
|
559
|
+
configPath: configFilePath,
|
|
560
|
+
typesPath: typesFilePath,
|
|
561
|
+
logger: input.logger
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
async function runRemoteUpgrade(input) {
|
|
565
|
+
const deployment = await readDeploymentFromContext();
|
|
566
|
+
const lambdaZip = await readLambdaZip();
|
|
567
|
+
input.logger.log(`Updating Lambda function code: ${deployment.lambdaArn}`);
|
|
568
|
+
const updateResult = await input.lambdaClient.send(
|
|
569
|
+
new UpdateFunctionCodeCommand({
|
|
570
|
+
FunctionName: deployment.lambdaArn,
|
|
571
|
+
ZipFile: lambdaZip
|
|
572
|
+
})
|
|
573
|
+
);
|
|
574
|
+
const lastModified = updateResult.LastModified ?? "unknown";
|
|
575
|
+
input.logger.log(`Upgrade complete. Last modified: ${lastModified}`);
|
|
576
|
+
}
|
|
577
|
+
async function readDeploymentFromContext() {
|
|
578
|
+
const context = await readAwsContextFromFile(contextFilePath);
|
|
579
|
+
if (context.deployment == null) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
"No deployment found in aws.context.json. Run `aws-accounts bootstrap` first."
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return context.deployment;
|
|
585
|
+
}
|
|
586
|
+
async function fetchCurrentState(props) {
|
|
587
|
+
if (!props.input.flags.refresh) {
|
|
588
|
+
const cache = await readStateCache(cachePath);
|
|
589
|
+
if (cache != null && isCacheFresh(cache, props.deployment.stateCacheTtlSeconds)) {
|
|
590
|
+
props.input.logger.log("Using cached state.");
|
|
591
|
+
return cache.state;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
props.input.logger.log("Fetching remote state...");
|
|
595
|
+
const clientConfig = buildAwsClientConfig({
|
|
596
|
+
profile: props.input.profile ?? (props.deployment.profile || void 0),
|
|
597
|
+
region: props.input.region ?? (props.deployment.region || void 0)
|
|
598
|
+
});
|
|
599
|
+
const lambdaClient = new LambdaClient(clientConfig);
|
|
600
|
+
const result = await invokeLambda({
|
|
601
|
+
lambdaClient,
|
|
602
|
+
lambdaArn: props.deployment.lambdaArn,
|
|
603
|
+
payload: { action: "getStateUrl" }
|
|
604
|
+
});
|
|
605
|
+
if (!result.ok) {
|
|
606
|
+
throw new Error(formatLambdaError(result.error));
|
|
607
|
+
}
|
|
608
|
+
const response = result.response;
|
|
609
|
+
if (!("action" in response) || response.action !== "getStateUrl") {
|
|
610
|
+
throw new Error("Unexpected response from Lambda getStateUrl action.");
|
|
611
|
+
}
|
|
612
|
+
const stateResponse = await fetch(response.url);
|
|
613
|
+
if (!stateResponse.ok) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`Failed to fetch state from pre-signed URL: ${stateResponse.status} ${stateResponse.statusText}`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const stateJson = await stateResponse.json();
|
|
619
|
+
const state = validateState(stateJson);
|
|
620
|
+
await writeStateCache(cachePath, state);
|
|
621
|
+
props.input.logger.log("State cache updated.");
|
|
622
|
+
return state;
|
|
623
|
+
}
|
|
624
|
+
function displayPlan(props) {
|
|
625
|
+
props.logger.log(
|
|
626
|
+
`Plan: ${props.plan.operations.length} operation(s), ${props.plan.unsupported.length} unsupported diff(s)`
|
|
627
|
+
);
|
|
628
|
+
const destructiveOperations = props.plan.operations.filter(
|
|
629
|
+
(op) => isDestructiveOperation(op)
|
|
630
|
+
);
|
|
631
|
+
if (destructiveOperations.length > 0) {
|
|
632
|
+
props.logger.log(
|
|
633
|
+
`Destructive operations detected: ${destructiveOperations.length}. Apply requires --allow-destructive.`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
for (const operation of props.plan.operations) {
|
|
637
|
+
props.logger.log(formatOperationLine(operation));
|
|
638
|
+
}
|
|
639
|
+
if (props.plan.unsupported.length > 0) {
|
|
640
|
+
props.logger.log("Unsupported diffs:");
|
|
641
|
+
for (const diff of props.plan.unsupported) {
|
|
642
|
+
props.logger.log(` - ${diff.description} [${diff.category}]`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function isDestructiveOperation(operation) {
|
|
647
|
+
return operation.kind === "deleteOu" || operation.kind === "removeAccount" || operation.kind === "deleteIdcUser" || operation.kind === "deleteIdcGroup" || operation.kind === "deleteIdcPermissionSet";
|
|
648
|
+
}
|
|
649
|
+
function formatOperationLine(operation) {
|
|
650
|
+
if (operation.kind === "moveAccount") {
|
|
651
|
+
return ` move account "${operation.accountName}" (${operation.accountId}) from ${operation.fromOuName} -> ${operation.toOuName}`;
|
|
652
|
+
}
|
|
653
|
+
if (operation.kind === "createOu") {
|
|
654
|
+
return ` create OU "${operation.ouName}" under ${operation.parentOuName}`;
|
|
655
|
+
}
|
|
656
|
+
if (operation.kind === "renameOu") {
|
|
657
|
+
return ` rename OU "${operation.fromOuName}" -> "${operation.toOuName}"`;
|
|
658
|
+
}
|
|
659
|
+
if (operation.kind === "deleteOu") {
|
|
660
|
+
return ` [destructive] delete OU "${operation.ouName}" from ${operation.parentOuName}`;
|
|
661
|
+
}
|
|
662
|
+
if (operation.kind === "createAccount") {
|
|
663
|
+
return ` create account "${operation.accountName}" (${operation.accountEmail}) in ${operation.targetOuName}`;
|
|
664
|
+
}
|
|
665
|
+
if (operation.kind === "updateAccountTags") {
|
|
666
|
+
return ` update account tags "${operation.accountName}" (${operation.accountId})`;
|
|
667
|
+
}
|
|
668
|
+
if (operation.kind === "updateAccountName") {
|
|
669
|
+
return ` rename account (${operation.accountId}): "${operation.fromAccountName}" -> "${operation.toAccountName}"`;
|
|
670
|
+
}
|
|
671
|
+
if (operation.kind === "removeAccount") {
|
|
672
|
+
return ` [destructive] move removed account "${operation.accountName}" (${operation.accountId}) from ${operation.fromOuName} -> ${operation.toOuName}`;
|
|
673
|
+
}
|
|
674
|
+
if (operation.kind === "createIdcUser") {
|
|
675
|
+
return ` create IdC user "${operation.userName}"`;
|
|
676
|
+
}
|
|
677
|
+
if (operation.kind === "updateIdcUser") {
|
|
678
|
+
return ` update IdC user "${operation.userName}"`;
|
|
679
|
+
}
|
|
680
|
+
if (operation.kind === "deleteIdcUser") {
|
|
681
|
+
return ` [destructive] delete IdC user "${operation.userName}"`;
|
|
682
|
+
}
|
|
683
|
+
if (operation.kind === "createIdcGroup") {
|
|
684
|
+
return ` create IdC group "${operation.groupDisplayName}"`;
|
|
685
|
+
}
|
|
686
|
+
if (operation.kind === "updateIdcGroupDescription") {
|
|
687
|
+
return ` update IdC group description for "${operation.groupDisplayName}"`;
|
|
688
|
+
}
|
|
689
|
+
if (operation.kind === "deleteIdcGroup") {
|
|
690
|
+
return ` [destructive] delete IdC group "${operation.groupDisplayName}"`;
|
|
691
|
+
}
|
|
692
|
+
if (operation.kind === "addIdcGroupMembership") {
|
|
693
|
+
return ` add user "${operation.userName}" to IdC group "${operation.groupDisplayName}"`;
|
|
694
|
+
}
|
|
695
|
+
if (operation.kind === "createIdcPermissionSet") {
|
|
696
|
+
return ` create IdC permission set "${operation.permissionSetName}"`;
|
|
697
|
+
}
|
|
698
|
+
if (operation.kind === "updateIdcPermissionSetDescription") {
|
|
699
|
+
return ` update IdC permission set description for "${operation.permissionSetName}"`;
|
|
700
|
+
}
|
|
701
|
+
if (operation.kind === "deleteIdcPermissionSet") {
|
|
702
|
+
return ` [destructive] delete IdC permission set "${operation.permissionSetName}"`;
|
|
703
|
+
}
|
|
704
|
+
if (operation.kind === "putIdcPermissionSetInlinePolicy") {
|
|
705
|
+
return ` put inline policy on IdC permission set "${operation.permissionSetName}"`;
|
|
706
|
+
}
|
|
707
|
+
if (operation.kind === "deleteIdcPermissionSetInlinePolicy") {
|
|
708
|
+
return ` delete inline policy from IdC permission set "${operation.permissionSetName}"`;
|
|
709
|
+
}
|
|
710
|
+
if (operation.kind === "attachIdcManagedPolicyToPermissionSet") {
|
|
711
|
+
return ` attach managed policy "${operation.managedPolicyArn}" to IdC permission set "${operation.permissionSetName}"`;
|
|
712
|
+
}
|
|
713
|
+
if (operation.kind === "detachIdcManagedPolicyFromPermissionSet") {
|
|
714
|
+
return ` detach managed policy "${operation.managedPolicyArn}" from IdC permission set "${operation.permissionSetName}"`;
|
|
715
|
+
}
|
|
716
|
+
if (operation.kind === "attachIdcCustomerManagedPolicyReferenceToPermissionSet") {
|
|
717
|
+
return ` attach customer-managed policy "${operation.customerManagedPolicyPath}${operation.customerManagedPolicyName}" to IdC permission set "${operation.permissionSetName}"`;
|
|
718
|
+
}
|
|
719
|
+
if (operation.kind === "detachIdcCustomerManagedPolicyReferenceFromPermissionSet") {
|
|
720
|
+
return ` detach customer-managed policy "${operation.customerManagedPolicyPath}${operation.customerManagedPolicyName}" from IdC permission set "${operation.permissionSetName}"`;
|
|
721
|
+
}
|
|
722
|
+
if (operation.kind === "provisionIdcPermissionSet") {
|
|
723
|
+
return ` provision IdC permission set "${operation.permissionSetName}" to all provisioned accounts`;
|
|
724
|
+
}
|
|
725
|
+
if (operation.kind === "removeIdcGroupMembership") {
|
|
726
|
+
return ` remove user "${operation.userName}" from IdC group "${operation.groupDisplayName}"`;
|
|
727
|
+
}
|
|
728
|
+
if (operation.kind === "grantIdcAccountAssignment") {
|
|
729
|
+
return ` grant IdC assignment "${operation.permissionSetName}" to ${formatPrincipalLabel(operation.principalType, operation.principalName)} on "${operation.accountName}"`;
|
|
730
|
+
}
|
|
731
|
+
if (operation.kind === "revokeIdcAccountAssignment") {
|
|
732
|
+
return ` revoke IdC assignment "${operation.permissionSetName}" from ${formatPrincipalLabel(operation.principalType, operation.principalName)} on "${operation.accountName}"`;
|
|
733
|
+
}
|
|
734
|
+
assertUnreachable(operation, "Unsupported operation kind in formatOperationLine.");
|
|
735
|
+
}
|
|
736
|
+
function formatPrincipalLabel(principalType, principalName) {
|
|
737
|
+
if (principalType === "GROUP") {
|
|
738
|
+
return `group "${principalName}"`;
|
|
739
|
+
}
|
|
740
|
+
return `user "${principalName}"`;
|
|
741
|
+
}
|
|
742
|
+
function formatLambdaError(error) {
|
|
743
|
+
if (error.kind === "validation") {
|
|
744
|
+
return `Lambda validation error: ${error.details}`;
|
|
745
|
+
}
|
|
746
|
+
if (error.kind === "concurrencyConflict") {
|
|
747
|
+
return `Lambda concurrency conflict: ${error.message}`;
|
|
748
|
+
}
|
|
749
|
+
if (error.kind === "operationFailed") {
|
|
750
|
+
return `Lambda operation failed: ${error.error}`;
|
|
751
|
+
}
|
|
752
|
+
if (error.kind === "invocationError") {
|
|
753
|
+
return `Lambda invocation error: ${error.message}`;
|
|
754
|
+
}
|
|
755
|
+
return `Lambda error: ${JSON.stringify(error)}`;
|
|
756
|
+
}
|
|
757
|
+
async function findPermissionSetByName(props) {
|
|
758
|
+
let nextToken;
|
|
759
|
+
do {
|
|
760
|
+
const listResponse = await props.ssoAdminClient.send(
|
|
761
|
+
new ListPermissionSetsCommand({
|
|
762
|
+
InstanceArn: props.instanceArn,
|
|
763
|
+
NextToken: nextToken
|
|
764
|
+
})
|
|
765
|
+
);
|
|
766
|
+
const permissionSetArns = listResponse.PermissionSets ?? [];
|
|
767
|
+
for (const arn of permissionSetArns) {
|
|
768
|
+
const describeResponse = await props.ssoAdminClient.send(
|
|
769
|
+
new DescribePermissionSetCommand({
|
|
770
|
+
InstanceArn: props.instanceArn,
|
|
771
|
+
PermissionSetArn: arn
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
if (describeResponse.PermissionSet?.Name === props.name) {
|
|
775
|
+
return arn;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
nextToken = listResponse.NextToken;
|
|
779
|
+
} while (nextToken != null);
|
|
780
|
+
return void 0;
|
|
781
|
+
}
|
|
782
|
+
async function ensureOrganizationManagementPermissionSet(props) {
|
|
783
|
+
const permissionSetName = "OrganizationManagement";
|
|
784
|
+
const description = "Full organization management access for AWS Organizations, IAM Identity Center, and IAM";
|
|
785
|
+
const sessionDuration = "PT4H";
|
|
786
|
+
const inlinePolicy = JSON.stringify({
|
|
787
|
+
Version: "2012-10-17",
|
|
788
|
+
Statement: [{
|
|
789
|
+
Effect: "Allow",
|
|
790
|
+
Action: [iam.organizations("*"), iam.sso("*"), iam.identitystore("*"), iam.account("*"), iam.iam("*")],
|
|
791
|
+
Resource: "*"
|
|
792
|
+
}]
|
|
793
|
+
});
|
|
794
|
+
const existingArn = await findPermissionSetByName({
|
|
795
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
796
|
+
instanceArn: props.instanceArn,
|
|
797
|
+
name: permissionSetName
|
|
798
|
+
});
|
|
799
|
+
const permissionSetArn = existingArn != null ? await updateExistingPermissionSet({
|
|
800
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
801
|
+
instanceArn: props.instanceArn,
|
|
802
|
+
permissionSetArn: existingArn,
|
|
803
|
+
permissionSetName,
|
|
804
|
+
description,
|
|
805
|
+
sessionDuration,
|
|
806
|
+
logger: props.logger
|
|
807
|
+
}) : await createNewPermissionSet({
|
|
808
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
809
|
+
instanceArn: props.instanceArn,
|
|
810
|
+
permissionSetName,
|
|
811
|
+
description,
|
|
812
|
+
sessionDuration,
|
|
813
|
+
tags: props.tags,
|
|
814
|
+
logger: props.logger
|
|
815
|
+
});
|
|
816
|
+
await props.ssoAdminClient.send(
|
|
817
|
+
new PutInlinePolicyToPermissionSetCommand({
|
|
818
|
+
InstanceArn: props.instanceArn,
|
|
819
|
+
PermissionSetArn: permissionSetArn,
|
|
820
|
+
InlinePolicy: inlinePolicy
|
|
821
|
+
})
|
|
822
|
+
);
|
|
823
|
+
await props.ssoAdminClient.send(
|
|
824
|
+
new SsoTagResourceCommand({
|
|
825
|
+
InstanceArn: props.instanceArn,
|
|
826
|
+
ResourceArn: permissionSetArn,
|
|
827
|
+
Tags: props.tags.map((t) => ({ Key: t.Key, Value: t.Value }))
|
|
828
|
+
})
|
|
829
|
+
);
|
|
830
|
+
return { permissionSetArn };
|
|
831
|
+
}
|
|
832
|
+
async function ensureOrganizationRemoteManagementPermissionSet(props) {
|
|
833
|
+
const permissionSetName = "OrganizationRemoteManagement";
|
|
834
|
+
const description = "Minimal access to invoke the beesolve-aws-accounts remote management Lambda";
|
|
835
|
+
const sessionDuration = "PT1H";
|
|
836
|
+
const inlinePolicy = JSON.stringify({
|
|
837
|
+
Version: "2012-10-17",
|
|
838
|
+
Statement: [{
|
|
839
|
+
Effect: "Allow",
|
|
840
|
+
Action: [iam.lambda("InvokeFunction")],
|
|
841
|
+
Resource: props.lambdaArn
|
|
842
|
+
}]
|
|
843
|
+
});
|
|
844
|
+
const existingArn = await findPermissionSetByName({
|
|
845
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
846
|
+
instanceArn: props.instanceArn,
|
|
847
|
+
name: permissionSetName
|
|
848
|
+
});
|
|
849
|
+
const permissionSetArn = existingArn != null ? await updateExistingPermissionSet({
|
|
850
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
851
|
+
instanceArn: props.instanceArn,
|
|
852
|
+
permissionSetArn: existingArn,
|
|
853
|
+
permissionSetName,
|
|
854
|
+
description,
|
|
855
|
+
sessionDuration,
|
|
856
|
+
logger: props.logger
|
|
857
|
+
}) : await createNewPermissionSet({
|
|
858
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
859
|
+
instanceArn: props.instanceArn,
|
|
860
|
+
permissionSetName,
|
|
861
|
+
description,
|
|
862
|
+
sessionDuration,
|
|
863
|
+
tags: props.tags,
|
|
864
|
+
logger: props.logger
|
|
865
|
+
});
|
|
866
|
+
await props.ssoAdminClient.send(
|
|
867
|
+
new PutInlinePolicyToPermissionSetCommand({
|
|
868
|
+
InstanceArn: props.instanceArn,
|
|
869
|
+
PermissionSetArn: permissionSetArn,
|
|
870
|
+
InlinePolicy: inlinePolicy
|
|
871
|
+
})
|
|
872
|
+
);
|
|
873
|
+
await props.ssoAdminClient.send(
|
|
874
|
+
new SsoTagResourceCommand({
|
|
875
|
+
InstanceArn: props.instanceArn,
|
|
876
|
+
ResourceArn: permissionSetArn,
|
|
877
|
+
Tags: props.tags.map((t) => ({ Key: t.Key, Value: t.Value }))
|
|
878
|
+
})
|
|
879
|
+
);
|
|
880
|
+
return { permissionSetArn };
|
|
881
|
+
}
|
|
882
|
+
async function updateExistingPermissionSet(props) {
|
|
883
|
+
await props.ssoAdminClient.send(
|
|
884
|
+
new UpdatePermissionSetCommand({
|
|
885
|
+
InstanceArn: props.instanceArn,
|
|
886
|
+
PermissionSetArn: props.permissionSetArn,
|
|
887
|
+
Description: props.description,
|
|
888
|
+
SessionDuration: props.sessionDuration
|
|
889
|
+
})
|
|
890
|
+
);
|
|
891
|
+
props.logger.log(`Updated permission set: ${props.permissionSetName}`);
|
|
892
|
+
return props.permissionSetArn;
|
|
893
|
+
}
|
|
894
|
+
async function createNewPermissionSet(props) {
|
|
895
|
+
const createResponse = await props.ssoAdminClient.send(
|
|
896
|
+
new CreatePermissionSetCommand({
|
|
897
|
+
InstanceArn: props.instanceArn,
|
|
898
|
+
Name: props.permissionSetName,
|
|
899
|
+
Description: props.description,
|
|
900
|
+
SessionDuration: props.sessionDuration,
|
|
901
|
+
Tags: props.tags.map((t) => ({ Key: t.Key, Value: t.Value }))
|
|
902
|
+
})
|
|
903
|
+
);
|
|
904
|
+
const permissionSetArn = createResponse.PermissionSet?.PermissionSetArn ?? "";
|
|
905
|
+
if (permissionSetArn === "") {
|
|
906
|
+
throw new Error(`Failed to create permission set "${props.permissionSetName}": ARN is empty.`);
|
|
907
|
+
}
|
|
908
|
+
props.logger.log(`Created permission set: ${props.permissionSetName}`);
|
|
909
|
+
return permissionSetArn;
|
|
910
|
+
}
|
|
911
|
+
async function readLambdaZip() {
|
|
912
|
+
try {
|
|
913
|
+
return await readFile(resolve(lambdaZipPath));
|
|
914
|
+
} catch {
|
|
915
|
+
throw new Error("dist-lambda/lambda.zip not found. Run `npm run build:lambda` first.");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
export {
|
|
919
|
+
runRemoteApply,
|
|
920
|
+
runRemoteBootstrap,
|
|
921
|
+
runRemoteInit,
|
|
922
|
+
runRemotePlan,
|
|
923
|
+
runRemoteScan,
|
|
924
|
+
runRemoteUpgrade
|
|
925
|
+
};
|