@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
package/dist/error.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class CliError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
constructor(kind, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.kind = kind;
|
|
6
|
+
this.name = "CliError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function toUsageError(message) {
|
|
10
|
+
return new CliError("usage", message);
|
|
11
|
+
}
|
|
12
|
+
function toValidationError(message) {
|
|
13
|
+
return new CliError("validation", message);
|
|
14
|
+
}
|
|
15
|
+
function toPreconditionError(message) {
|
|
16
|
+
return new CliError("precondition", message);
|
|
17
|
+
}
|
|
18
|
+
function classifyCliError(error) {
|
|
19
|
+
if (error instanceof CliError) {
|
|
20
|
+
return { kind: error.kind, message: error.message };
|
|
21
|
+
}
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
if (isUsageErrorMessage(message)) {
|
|
24
|
+
return { kind: "usage", message };
|
|
25
|
+
}
|
|
26
|
+
if (isValidationErrorMessage(message)) {
|
|
27
|
+
return { kind: "validation", message };
|
|
28
|
+
}
|
|
29
|
+
if (isPreconditionErrorMessage(message)) {
|
|
30
|
+
return { kind: "precondition", message };
|
|
31
|
+
}
|
|
32
|
+
return { kind: "runtime", message };
|
|
33
|
+
}
|
|
34
|
+
function exitCodeForCliErrorKind(kind) {
|
|
35
|
+
if (kind === "usage") {
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
if (kind === "validation") {
|
|
39
|
+
return 3;
|
|
40
|
+
}
|
|
41
|
+
if (kind === "precondition") {
|
|
42
|
+
return 4;
|
|
43
|
+
}
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
function isUsageErrorMessage(message) {
|
|
47
|
+
return message.includes("Missing required --") || message.includes(
|
|
48
|
+
"Refusing to create organizational units in non-interactive mode without --yes."
|
|
49
|
+
) || message.includes(
|
|
50
|
+
"Refusing to overwrite config files in non-interactive mode without --yes."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function isValidationErrorMessage(message) {
|
|
54
|
+
return message.includes("Invalid --");
|
|
55
|
+
}
|
|
56
|
+
function isPreconditionErrorMessage(message) {
|
|
57
|
+
return message.includes("Could not find") || message.includes("must exist") || message.includes("Re-run bootstrap") || message.includes("state/context mismatch") || message.includes("aws.context.json conflicts");
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
CliError,
|
|
61
|
+
classifyCliError,
|
|
62
|
+
exitCodeForCliErrorKind,
|
|
63
|
+
toPreconditionError,
|
|
64
|
+
toUsageError,
|
|
65
|
+
toValidationError
|
|
66
|
+
};
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function assertUnreachable(value, message = JSON.stringify(value)) {
|
|
2
|
+
throw Error("An unreachable state reached!\n" + message);
|
|
3
|
+
}
|
|
4
|
+
function toRecordByProperty(input, key, keyTransformer = (key2) => key2) {
|
|
5
|
+
return Object.fromEntries(
|
|
6
|
+
input.map((item) => [
|
|
7
|
+
keyTransformer(typeof key === "function" ? key(item) : item[key]),
|
|
8
|
+
item
|
|
9
|
+
])
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
async function delay(ms) {
|
|
13
|
+
await new Promise((resolve) => {
|
|
14
|
+
setTimeout(resolve, ms);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
assertUnreachable,
|
|
19
|
+
delay,
|
|
20
|
+
toRecordByProperty
|
|
21
|
+
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GetObjectCommand,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
S3Client,
|
|
5
|
+
S3ServiceException
|
|
6
|
+
} from "@aws-sdk/client-s3";
|
|
7
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
8
|
+
import { OrganizationsClient } from "@aws-sdk/client-organizations";
|
|
9
|
+
import { SSOAdminClient } from "@aws-sdk/client-sso-admin";
|
|
10
|
+
import { IdentitystoreClient } from "@aws-sdk/client-identitystore";
|
|
11
|
+
import { AccountClient } from "@aws-sdk/client-account";
|
|
12
|
+
import * as v from "valibot";
|
|
13
|
+
import { operationSchema } from "../operations.js";
|
|
14
|
+
import {
|
|
15
|
+
stateSchema,
|
|
16
|
+
createWorkingState,
|
|
17
|
+
materializeWorkingState
|
|
18
|
+
} from "../state.js";
|
|
19
|
+
import { scanOrganization, scanIdentityCenter } from "../scanLogic.js";
|
|
20
|
+
import { executeOperation } from "../applyLogic.js";
|
|
21
|
+
import { assertUnreachable } from "../helpers.js";
|
|
22
|
+
const scanRequestSchema = v.strictObject({
|
|
23
|
+
action: v.literal("scan")
|
|
24
|
+
});
|
|
25
|
+
const getStateUrlRequestSchema = v.strictObject({
|
|
26
|
+
action: v.literal("getStateUrl")
|
|
27
|
+
});
|
|
28
|
+
const applyRequestSchema = v.strictObject({
|
|
29
|
+
action: v.literal("apply"),
|
|
30
|
+
operations: v.pipe(v.array(operationSchema), v.minLength(1)),
|
|
31
|
+
allowDestructive: v.boolean()
|
|
32
|
+
});
|
|
33
|
+
const lambdaRequestSchema = v.variant("action", [
|
|
34
|
+
scanRequestSchema,
|
|
35
|
+
getStateUrlRequestSchema,
|
|
36
|
+
applyRequestSchema
|
|
37
|
+
]);
|
|
38
|
+
const scanResponseSchema = v.strictObject({
|
|
39
|
+
action: v.literal("scan"),
|
|
40
|
+
success: v.literal(true),
|
|
41
|
+
summary: v.strictObject({
|
|
42
|
+
organizationalUnits: v.number(),
|
|
43
|
+
accounts: v.number(),
|
|
44
|
+
users: v.number(),
|
|
45
|
+
groups: v.number(),
|
|
46
|
+
permissionSets: v.number(),
|
|
47
|
+
accountAssignments: v.number()
|
|
48
|
+
}),
|
|
49
|
+
state: stateSchema
|
|
50
|
+
});
|
|
51
|
+
const getStateUrlResponseSchema = v.strictObject({
|
|
52
|
+
action: v.literal("getStateUrl"),
|
|
53
|
+
success: v.literal(true),
|
|
54
|
+
url: v.string(),
|
|
55
|
+
expiresInSeconds: v.number()
|
|
56
|
+
});
|
|
57
|
+
const applySuccessResponseSchema = v.strictObject({
|
|
58
|
+
action: v.literal("apply"),
|
|
59
|
+
success: v.literal(true),
|
|
60
|
+
operationsCompleted: v.number(),
|
|
61
|
+
state: stateSchema
|
|
62
|
+
});
|
|
63
|
+
const errorResponseSchema = v.strictObject({
|
|
64
|
+
success: v.literal(false),
|
|
65
|
+
error: v.strictObject({
|
|
66
|
+
kind: v.picklist([
|
|
67
|
+
"validation",
|
|
68
|
+
"concurrencyConflict",
|
|
69
|
+
"operationFailed",
|
|
70
|
+
"internal"
|
|
71
|
+
]),
|
|
72
|
+
message: v.string(),
|
|
73
|
+
details: v.optional(
|
|
74
|
+
v.strictObject({
|
|
75
|
+
failedOperation: v.optional(v.number()),
|
|
76
|
+
operationsCompleted: v.optional(v.number()),
|
|
77
|
+
partialState: v.optional(stateSchema),
|
|
78
|
+
validationIssues: v.optional(v.array(v.string()))
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
});
|
|
83
|
+
const lambdaResponseSchema = v.union([
|
|
84
|
+
scanResponseSchema,
|
|
85
|
+
getStateUrlResponseSchema,
|
|
86
|
+
applySuccessResponseSchema,
|
|
87
|
+
errorResponseSchema
|
|
88
|
+
]);
|
|
89
|
+
const STATE_KEY = "state.json";
|
|
90
|
+
const PRESIGNED_URL_EXPIRY_SECONDS = 3600;
|
|
91
|
+
const RUNTIME_DEFAULTS = {
|
|
92
|
+
createAccount: {
|
|
93
|
+
timeoutInMs: 3e5,
|
|
94
|
+
pollIntervalInMs: 5e3
|
|
95
|
+
},
|
|
96
|
+
accountAssignment: {
|
|
97
|
+
timeoutInMs: 6e4,
|
|
98
|
+
pollIntervalInMs: 2e3
|
|
99
|
+
},
|
|
100
|
+
permissionSetProvisioning: {
|
|
101
|
+
timeoutInMs: 6e4,
|
|
102
|
+
pollIntervalInMs: 2e3
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const lambdaLogger = {
|
|
106
|
+
log: (...args) => console.log(...args),
|
|
107
|
+
info: (...args) => console.info(...args),
|
|
108
|
+
warn: (...args) => console.warn(...args),
|
|
109
|
+
error: (...args) => console.error(...args),
|
|
110
|
+
debug: (...args) => console.debug(...args),
|
|
111
|
+
trace: (...args) => console.trace(...args)
|
|
112
|
+
};
|
|
113
|
+
const s3Client = new S3Client({});
|
|
114
|
+
const organizationsClient = new OrganizationsClient({});
|
|
115
|
+
const ssoAdminClient = new SSOAdminClient({});
|
|
116
|
+
const identityStoreClient = new IdentitystoreClient({});
|
|
117
|
+
const accountClient = new AccountClient({});
|
|
118
|
+
async function handler(event) {
|
|
119
|
+
try {
|
|
120
|
+
const parseResult = v.safeParse(lambdaRequestSchema, event);
|
|
121
|
+
if (!parseResult.success) {
|
|
122
|
+
const issues = parseResult.issues.map(
|
|
123
|
+
(issue) => `${issue.path?.map((p) => p.key).join(".") ?? "root"}: ${issue.message}`
|
|
124
|
+
);
|
|
125
|
+
const response = buildErrorResponse(
|
|
126
|
+
"validation",
|
|
127
|
+
"Invalid request payload.",
|
|
128
|
+
{ validationIssues: issues }
|
|
129
|
+
);
|
|
130
|
+
return validateResponse(response);
|
|
131
|
+
}
|
|
132
|
+
const request = parseResult.output;
|
|
133
|
+
const bucket = process.env.STATE_BUCKET_NAME;
|
|
134
|
+
if (bucket == null || bucket.length === 0) {
|
|
135
|
+
const response = buildErrorResponse(
|
|
136
|
+
"internal",
|
|
137
|
+
"STATE_BUCKET_NAME environment variable is not configured."
|
|
138
|
+
);
|
|
139
|
+
return validateResponse(response);
|
|
140
|
+
}
|
|
141
|
+
if (request.action === "scan") {
|
|
142
|
+
const response = await handleScan({ s3Client, bucket, organizationsClient, ssoAdminClient, identityStoreClient });
|
|
143
|
+
return validateResponse(response);
|
|
144
|
+
}
|
|
145
|
+
if (request.action === "getStateUrl") {
|
|
146
|
+
const response = await handleGetStateUrl({ s3Client, bucket });
|
|
147
|
+
return validateResponse(response);
|
|
148
|
+
}
|
|
149
|
+
if (request.action === "apply") {
|
|
150
|
+
const response = await handleApply({
|
|
151
|
+
s3Client,
|
|
152
|
+
bucket,
|
|
153
|
+
operations: request.operations,
|
|
154
|
+
allowDestructive: request.allowDestructive,
|
|
155
|
+
organizationsClient,
|
|
156
|
+
ssoAdminClient,
|
|
157
|
+
identityStoreClient,
|
|
158
|
+
accountClient
|
|
159
|
+
});
|
|
160
|
+
return validateResponse(response);
|
|
161
|
+
}
|
|
162
|
+
assertUnreachable(request, "Unsupported action in handler.");
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : "An unexpected error occurred.";
|
|
165
|
+
const response = buildErrorResponse("internal", message);
|
|
166
|
+
return validateResponse(response);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function buildErrorResponse(kind, message, details) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
error: {
|
|
173
|
+
kind,
|
|
174
|
+
message,
|
|
175
|
+
...details != null ? { details } : {}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function validateResponse(response) {
|
|
180
|
+
const result = v.safeParse(lambdaResponseSchema, response);
|
|
181
|
+
if (!result.success) {
|
|
182
|
+
return {
|
|
183
|
+
success: false,
|
|
184
|
+
error: {
|
|
185
|
+
kind: "internal",
|
|
186
|
+
message: "Response validation failed before returning.",
|
|
187
|
+
details: {
|
|
188
|
+
validationIssues: result.issues.map(
|
|
189
|
+
(issue) => `${issue.path?.map((p) => p.key).join(".") ?? "root"}: ${issue.message}`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return result.output;
|
|
196
|
+
}
|
|
197
|
+
async function readStateFromS3(props) {
|
|
198
|
+
const response = await props.s3Client.send(
|
|
199
|
+
new GetObjectCommand({
|
|
200
|
+
Bucket: props.bucket,
|
|
201
|
+
Key: STATE_KEY
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
const body = await response.Body?.transformToString();
|
|
205
|
+
if (body == null) {
|
|
206
|
+
throw new Error("State not found. Run remote scan first.");
|
|
207
|
+
}
|
|
208
|
+
const parsed = JSON.parse(body);
|
|
209
|
+
const state = v.parse(stateSchema, parsed);
|
|
210
|
+
const etag = response.ETag ?? "";
|
|
211
|
+
return { state, etag };
|
|
212
|
+
}
|
|
213
|
+
async function writeStateToS3(props) {
|
|
214
|
+
await props.s3Client.send(new PutObjectCommand({
|
|
215
|
+
Bucket: props.bucket,
|
|
216
|
+
Key: STATE_KEY,
|
|
217
|
+
Body: JSON.stringify(props.state, null, 2),
|
|
218
|
+
ContentType: "application/json",
|
|
219
|
+
IfMatch: props.ifMatch
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
function isS3PreconditionFailed(error) {
|
|
223
|
+
if (error instanceof S3ServiceException) {
|
|
224
|
+
return error.name === "PreconditionFailed" || error.$metadata?.httpStatusCode === 412;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
async function handleScan(props) {
|
|
229
|
+
const identityCenterInstanceArn = process.env.IDENTITY_CENTER_INSTANCE_ARN || void 0;
|
|
230
|
+
const [organization, identityCenter] = await Promise.all([
|
|
231
|
+
scanOrganization({ organizationsClient: props.organizationsClient }),
|
|
232
|
+
scanIdentityCenter({
|
|
233
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
234
|
+
identityStoreClient: props.identityStoreClient,
|
|
235
|
+
requestedInstanceArn: identityCenterInstanceArn
|
|
236
|
+
})
|
|
237
|
+
]);
|
|
238
|
+
const state = {
|
|
239
|
+
version: "1",
|
|
240
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
241
|
+
organization,
|
|
242
|
+
identityCenter
|
|
243
|
+
};
|
|
244
|
+
await writeStateToS3({
|
|
245
|
+
s3Client: props.s3Client,
|
|
246
|
+
bucket: props.bucket,
|
|
247
|
+
state
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
action: "scan",
|
|
251
|
+
success: true,
|
|
252
|
+
summary: {
|
|
253
|
+
organizationalUnits: state.organization.organizationalUnits.length,
|
|
254
|
+
accounts: state.organization.accounts.length,
|
|
255
|
+
users: state.identityCenter.users.length,
|
|
256
|
+
groups: state.identityCenter.groups.length,
|
|
257
|
+
permissionSets: state.identityCenter.permissionSets.length,
|
|
258
|
+
accountAssignments: state.identityCenter.accountAssignments.length
|
|
259
|
+
},
|
|
260
|
+
state
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
async function handleGetStateUrl(props) {
|
|
264
|
+
const command = new GetObjectCommand({
|
|
265
|
+
Bucket: props.bucket,
|
|
266
|
+
Key: STATE_KEY
|
|
267
|
+
});
|
|
268
|
+
const url = await getSignedUrl(props.s3Client, command, {
|
|
269
|
+
expiresIn: PRESIGNED_URL_EXPIRY_SECONDS
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
action: "getStateUrl",
|
|
273
|
+
success: true,
|
|
274
|
+
url,
|
|
275
|
+
expiresInSeconds: PRESIGNED_URL_EXPIRY_SECONDS
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async function handleApply(props) {
|
|
279
|
+
const stateResult = await loadStateForApply({
|
|
280
|
+
s3Client: props.s3Client,
|
|
281
|
+
bucket: props.bucket
|
|
282
|
+
});
|
|
283
|
+
if (!stateResult.ok) {
|
|
284
|
+
return stateResult.response;
|
|
285
|
+
}
|
|
286
|
+
const { state: currentState, etag } = stateResult;
|
|
287
|
+
let workingState = createWorkingState({ state: currentState });
|
|
288
|
+
let operationsCompleted = 0;
|
|
289
|
+
for (let i = 0; i < props.operations.length; i++) {
|
|
290
|
+
const operation = props.operations[i];
|
|
291
|
+
try {
|
|
292
|
+
workingState = await executeOperation({
|
|
293
|
+
state: workingState,
|
|
294
|
+
organizationsClient: props.organizationsClient,
|
|
295
|
+
accountClient: props.accountClient,
|
|
296
|
+
ssoAdminClient: props.ssoAdminClient,
|
|
297
|
+
identityStoreClient: props.identityStoreClient,
|
|
298
|
+
logger: lambdaLogger,
|
|
299
|
+
context: {
|
|
300
|
+
organization: {
|
|
301
|
+
rootId: workingState.organization.rootId
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
runtime: RUNTIME_DEFAULTS,
|
|
305
|
+
operation
|
|
306
|
+
});
|
|
307
|
+
operationsCompleted++;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
const partialState = materializeWorkingState({ workingState });
|
|
310
|
+
try {
|
|
311
|
+
await writeStateToS3({
|
|
312
|
+
s3Client: props.s3Client,
|
|
313
|
+
bucket: props.bucket,
|
|
314
|
+
state: partialState,
|
|
315
|
+
ifMatch: etag
|
|
316
|
+
});
|
|
317
|
+
} catch (writeError) {
|
|
318
|
+
if (isS3PreconditionFailed(writeError)) {
|
|
319
|
+
return buildErrorResponse(
|
|
320
|
+
"concurrencyConflict",
|
|
321
|
+
"Concurrent state modification detected while writing partial state."
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
lambdaLogger.error(
|
|
325
|
+
"Failed to write partial state after operation failure:",
|
|
326
|
+
writeError
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown operation error";
|
|
330
|
+
return buildErrorResponse("operationFailed", errorMessage, {
|
|
331
|
+
failedOperation: i,
|
|
332
|
+
operationsCompleted,
|
|
333
|
+
partialState
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const finalState = materializeWorkingState({ workingState });
|
|
338
|
+
try {
|
|
339
|
+
await writeStateToS3({
|
|
340
|
+
s3Client: props.s3Client,
|
|
341
|
+
bucket: props.bucket,
|
|
342
|
+
state: finalState,
|
|
343
|
+
ifMatch: etag
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (isS3PreconditionFailed(error)) {
|
|
347
|
+
return buildErrorResponse(
|
|
348
|
+
"concurrencyConflict",
|
|
349
|
+
"Concurrent state modification detected. Another apply may have completed while this one was running."
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
action: "apply",
|
|
356
|
+
success: true,
|
|
357
|
+
operationsCompleted,
|
|
358
|
+
state: finalState
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function loadStateForApply(props) {
|
|
362
|
+
try {
|
|
363
|
+
const result = await readStateFromS3({
|
|
364
|
+
s3Client: props.s3Client,
|
|
365
|
+
bucket: props.bucket
|
|
366
|
+
});
|
|
367
|
+
return { ok: true, state: result.state, etag: result.etag };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
const message = error instanceof Error ? error.message : "Failed to read state from S3.";
|
|
370
|
+
return { ok: false, response: buildErrorResponse("internal", message) };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
export {
|
|
374
|
+
handler
|
|
375
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InvokeCommand,
|
|
3
|
+
TooManyRequestsException
|
|
4
|
+
} from "@aws-sdk/client-lambda";
|
|
5
|
+
import * as v from "valibot";
|
|
6
|
+
import { assertUnreachable } from "./helpers.js";
|
|
7
|
+
import { operationSchema } from "./operations.js";
|
|
8
|
+
import { stateSchema } from "./state.js";
|
|
9
|
+
const scanRequestSchema = v.strictObject({
|
|
10
|
+
action: v.literal("scan")
|
|
11
|
+
});
|
|
12
|
+
const getStateUrlRequestSchema = v.strictObject({
|
|
13
|
+
action: v.literal("getStateUrl")
|
|
14
|
+
});
|
|
15
|
+
const applyRequestSchema = v.strictObject({
|
|
16
|
+
action: v.literal("apply"),
|
|
17
|
+
operations: v.pipe(v.array(operationSchema), v.minLength(1)),
|
|
18
|
+
allowDestructive: v.boolean()
|
|
19
|
+
});
|
|
20
|
+
const lambdaRequestSchema = v.variant("action", [
|
|
21
|
+
scanRequestSchema,
|
|
22
|
+
getStateUrlRequestSchema,
|
|
23
|
+
applyRequestSchema
|
|
24
|
+
]);
|
|
25
|
+
const scanResponseSchema = v.strictObject({
|
|
26
|
+
action: v.literal("scan"),
|
|
27
|
+
success: v.literal(true),
|
|
28
|
+
summary: v.strictObject({
|
|
29
|
+
organizationalUnits: v.number(),
|
|
30
|
+
accounts: v.number(),
|
|
31
|
+
users: v.number(),
|
|
32
|
+
groups: v.number(),
|
|
33
|
+
permissionSets: v.number(),
|
|
34
|
+
accountAssignments: v.number()
|
|
35
|
+
}),
|
|
36
|
+
state: stateSchema
|
|
37
|
+
});
|
|
38
|
+
const getStateUrlResponseSchema = v.strictObject({
|
|
39
|
+
action: v.literal("getStateUrl"),
|
|
40
|
+
success: v.literal(true),
|
|
41
|
+
url: v.string(),
|
|
42
|
+
expiresInSeconds: v.number()
|
|
43
|
+
});
|
|
44
|
+
const applySuccessResponseSchema = v.strictObject({
|
|
45
|
+
action: v.literal("apply"),
|
|
46
|
+
success: v.literal(true),
|
|
47
|
+
operationsCompleted: v.number(),
|
|
48
|
+
state: stateSchema
|
|
49
|
+
});
|
|
50
|
+
const errorResponseSchema = v.strictObject({
|
|
51
|
+
success: v.literal(false),
|
|
52
|
+
error: v.strictObject({
|
|
53
|
+
kind: v.picklist([
|
|
54
|
+
"validation",
|
|
55
|
+
"concurrencyConflict",
|
|
56
|
+
"operationFailed",
|
|
57
|
+
"internal"
|
|
58
|
+
]),
|
|
59
|
+
message: v.string(),
|
|
60
|
+
details: v.optional(
|
|
61
|
+
v.strictObject({
|
|
62
|
+
failedOperation: v.optional(v.number()),
|
|
63
|
+
operationsCompleted: v.optional(v.number()),
|
|
64
|
+
partialState: v.optional(stateSchema),
|
|
65
|
+
validationIssues: v.optional(v.array(v.string()))
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
});
|
|
70
|
+
const lambdaResponseSchema = v.union([
|
|
71
|
+
scanResponseSchema,
|
|
72
|
+
getStateUrlResponseSchema,
|
|
73
|
+
applySuccessResponseSchema,
|
|
74
|
+
errorResponseSchema
|
|
75
|
+
]);
|
|
76
|
+
async function invokeLambdaCommand(lambdaClient, lambdaArn, payload) {
|
|
77
|
+
try {
|
|
78
|
+
const response = await lambdaClient.send(
|
|
79
|
+
new InvokeCommand({
|
|
80
|
+
FunctionName: lambdaArn,
|
|
81
|
+
InvocationType: "RequestResponse",
|
|
82
|
+
Payload: new TextEncoder().encode(JSON.stringify(payload))
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
return { ok: true, response };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof TooManyRequestsException) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: {
|
|
91
|
+
kind: "concurrencyConflict",
|
|
92
|
+
message: "Lambda concurrency limit reached. Another operation may be in progress."
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const message = error instanceof Error ? error.message : "Unknown invocation error";
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: { kind: "invocationError", message }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function parseResponsePayload(payload) {
|
|
104
|
+
try {
|
|
105
|
+
const responseText = new TextDecoder().decode(payload);
|
|
106
|
+
return { ok: true, value: JSON.parse(responseText) };
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function invokeLambda(props) {
|
|
112
|
+
const invokeResult = await invokeLambdaCommand(props.lambdaClient, props.lambdaArn, props.payload);
|
|
113
|
+
if (!invokeResult.ok) return invokeResult;
|
|
114
|
+
const rawResponse = invokeResult.response;
|
|
115
|
+
if (rawResponse.FunctionError) {
|
|
116
|
+
const errorPayload = rawResponse.Payload ? new TextDecoder().decode(rawResponse.Payload) : "Lambda function execution failed";
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: { kind: "invocationError", message: errorPayload }
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!rawResponse.Payload) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: { kind: "invocationError", message: "Empty response payload" }
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const parsed = parseResponsePayload(rawResponse.Payload);
|
|
129
|
+
if (!parsed.ok) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: {
|
|
133
|
+
kind: "invocationError",
|
|
134
|
+
message: "Failed to parse Lambda response as JSON"
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const result = v.safeParse(lambdaResponseSchema, parsed.value);
|
|
139
|
+
if (!result.success) {
|
|
140
|
+
const issues = result.issues.map((issue) => `${issue.path?.map((p) => p.key).join(".") ?? "root"}: ${issue.message}`).join("; ");
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: {
|
|
144
|
+
kind: "validation",
|
|
145
|
+
details: `Lambda response validation failed: ${issues}`
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const response = result.output;
|
|
150
|
+
if ("success" in response && response.success === false) {
|
|
151
|
+
const errorKind = response.error.kind;
|
|
152
|
+
if (errorKind === "validation") {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: {
|
|
156
|
+
kind: "validation",
|
|
157
|
+
details: response.error.message
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (errorKind === "concurrencyConflict") {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: {
|
|
165
|
+
kind: "concurrencyConflict",
|
|
166
|
+
message: response.error.message
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (errorKind === "operationFailed") {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: {
|
|
174
|
+
kind: "operationFailed",
|
|
175
|
+
failedOperation: response.error.details?.failedOperation ?? 0,
|
|
176
|
+
totalOperations: (response.error.details?.operationsCompleted ?? 0) + 1,
|
|
177
|
+
error: response.error.message,
|
|
178
|
+
partialState: response.error.details?.partialState ?? buildEmptyStateForError()
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (errorKind === "internal") {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: {
|
|
186
|
+
kind: "invocationError",
|
|
187
|
+
message: response.error.message
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
assertUnreachable(errorKind, "Unsupported error kind in Lambda response.");
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, response };
|
|
194
|
+
}
|
|
195
|
+
function buildEmptyStateForError() {
|
|
196
|
+
return {
|
|
197
|
+
version: "1",
|
|
198
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
199
|
+
organization: {
|
|
200
|
+
rootId: "",
|
|
201
|
+
organizationalUnits: [],
|
|
202
|
+
accounts: []
|
|
203
|
+
},
|
|
204
|
+
identityCenter: {
|
|
205
|
+
instanceArn: "",
|
|
206
|
+
identityStoreId: "",
|
|
207
|
+
users: [],
|
|
208
|
+
groups: [],
|
|
209
|
+
groupMemberships: [],
|
|
210
|
+
permissionSets: [],
|
|
211
|
+
accountAssignments: [],
|
|
212
|
+
accessRoles: []
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export {
|
|
217
|
+
invokeLambda,
|
|
218
|
+
lambdaRequestSchema,
|
|
219
|
+
lambdaResponseSchema
|
|
220
|
+
};
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const consoleLogger = {
|
|
2
|
+
log: (...args) => console.log(...args),
|
|
3
|
+
info: (...args) => console.info(...args),
|
|
4
|
+
warn: (...args) => console.warn(...args),
|
|
5
|
+
error: (...args) => console.error(...args),
|
|
6
|
+
debug: (...args) => console.debug(...args),
|
|
7
|
+
trace: (...args) => console.trace(...args)
|
|
8
|
+
};
|
|
9
|
+
const noopLogger = {
|
|
10
|
+
log: () => {
|
|
11
|
+
},
|
|
12
|
+
info: () => {
|
|
13
|
+
},
|
|
14
|
+
warn: () => {
|
|
15
|
+
},
|
|
16
|
+
error: () => {
|
|
17
|
+
},
|
|
18
|
+
debug: () => {
|
|
19
|
+
},
|
|
20
|
+
trace: () => {
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export {
|
|
24
|
+
consoleLogger,
|
|
25
|
+
noopLogger
|
|
26
|
+
};
|