@intentius/chant-lexicon-aws 0.124.23 → 0.124.25
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/dist/api/credential-chain.d.ts +114 -0
- package/dist/api/credential-chain.d.ts.map +1 -0
- package/dist/api/read-client.d.ts +20 -0
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/caller-identity.d.ts +25 -6
- package/dist/caller-identity.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/property-overlay.d.ts +60 -0
- package/dist/codegen/property-overlay.d.ts.map +1 -0
- package/dist/codegen/property-overlay.json +19 -0
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +7 -0
- package/dist/okf/types/Runtime.md +1 -0
- package/dist/op/activities/floci.d.ts +10 -1
- package/dist/op/activities/floci.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/package.json +2 -2
- package/src/api/credential-chain.test.ts +70 -0
- package/src/api/credential-chain.ts +536 -0
- package/src/api/read-client.ts +54 -0
- package/src/caller-identity.test.ts +301 -16
- package/src/caller-identity.ts +43 -21
- package/src/codegen/generate.ts +23 -1
- package/src/codegen/property-overlay.json +19 -0
- package/src/codegen/property-overlay.test.ts +61 -0
- package/src/codegen/property-overlay.ts +125 -0
- package/src/composites/agentcore-agent.test.ts +26 -1
- package/src/generated/index.d.ts +2 -0
- package/src/generated/lexicon-aws.json +7 -0
- package/src/op/activities/floci.test.ts +26 -0
- package/src/op/activities/floci.ts +17 -1
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* because a whoami that names a binding the read does not use is worse than no
|
|
8
8
|
* whoami at all.
|
|
9
9
|
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
10
11
|
import { describe, test, expect } from "vitest";
|
|
11
|
-
import { describeIdentity } from "./caller-identity";
|
|
12
|
+
import { describeIdentity, type CallerIdentityOptions } from "./caller-identity";
|
|
12
13
|
import { describeStackResources, type AwsReadHttp } from "./api/read-client";
|
|
13
14
|
|
|
14
15
|
const CALLER_IDENTITY_XML = `
|
|
@@ -36,13 +37,24 @@ function recording(text: string, status = 200) {
|
|
|
36
37
|
return { http, calls };
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* No shared config or credentials files: the chain must never read the real
|
|
42
|
+
* `~/.aws` of whoever runs the suite. Tests that exercise profiles inject
|
|
43
|
+
* their own files.
|
|
44
|
+
*/
|
|
45
|
+
const NO_FILES = { home: "/nonexistent-home", readFile: () => undefined };
|
|
46
|
+
|
|
47
|
+
function whoami(options: CallerIdentityOptions) {
|
|
48
|
+
return describeIdentity({ ...options, chain: { ...NO_FILES, ...options.chain } });
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
/** Credentials that exist only in this record — never read from the process. */
|
|
40
52
|
const CREDS = { AWS_ACCESS_KEY_ID: "AKIAEXAMPLEKEYID0000", AWS_SECRET_ACCESS_KEY: "notarealsecret" };
|
|
41
53
|
|
|
42
54
|
describe("aws describeIdentity (#1982)", () => {
|
|
43
55
|
test("reports the STS principal, the account+region scope, and where the binding came from", async () => {
|
|
44
56
|
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
45
|
-
const result = await
|
|
57
|
+
const result = await whoami({
|
|
46
58
|
environment: "prod",
|
|
47
59
|
region: "eu-west-1",
|
|
48
60
|
client: { http, env: CREDS },
|
|
@@ -63,31 +75,33 @@ describe("aws describeIdentity (#1982)", () => {
|
|
|
63
75
|
|
|
64
76
|
test("no environment credentials against real AWS is no-credentials, and the call is never made", async () => {
|
|
65
77
|
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
66
|
-
const result = await
|
|
78
|
+
const result = await whoami({ environment: "prod", client: { http, env: {} } });
|
|
67
79
|
expect(result).toEqual({
|
|
68
80
|
unresolved: {
|
|
69
81
|
reason: "no-credentials",
|
|
70
|
-
detail:
|
|
82
|
+
detail:
|
|
83
|
+
"no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment, no AWS_PROFILE, and no default profile in /nonexistent-home/.aws/config or /nonexistent-home/.aws/credentials",
|
|
71
84
|
},
|
|
72
85
|
});
|
|
73
86
|
expect(calls).toHaveLength(0);
|
|
74
87
|
});
|
|
75
88
|
|
|
76
|
-
test("AWS_PROFILE
|
|
77
|
-
const { http } = recording(CALLER_IDENTITY_XML);
|
|
78
|
-
const result = await
|
|
89
|
+
test("an AWS_PROFILE that names no profile is no-credentials, naming the profile and the files read", async () => {
|
|
90
|
+
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
91
|
+
const result = await whoami({
|
|
79
92
|
environment: "prod",
|
|
80
93
|
client: { http, env: { AWS_PROFILE: "acme-ci" } },
|
|
81
94
|
});
|
|
82
95
|
expect(result).toMatchObject({ unresolved: { reason: "no-credentials" } });
|
|
83
96
|
const detail = (result as { unresolved: { detail: string } }).unresolved.detail;
|
|
84
97
|
expect(detail).toContain("AWS_PROFILE=acme-ci");
|
|
85
|
-
expect(detail).toContain("
|
|
98
|
+
expect(detail).toContain("/nonexistent-home/.aws/config");
|
|
99
|
+
expect(calls).toHaveLength(0);
|
|
86
100
|
});
|
|
87
101
|
|
|
88
102
|
test("an unsigned emulator read still resolves an identity, and says it was unsigned", async () => {
|
|
89
103
|
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
90
|
-
const result = await
|
|
104
|
+
const result = await whoami({
|
|
91
105
|
environment: "floci",
|
|
92
106
|
client: { http, env: { AWS_ENDPOINT_URL: "http://localhost:4566" } },
|
|
93
107
|
});
|
|
@@ -106,21 +120,21 @@ describe("aws describeIdentity (#1982)", () => {
|
|
|
106
120
|
403,
|
|
107
121
|
);
|
|
108
122
|
expect(
|
|
109
|
-
await
|
|
123
|
+
await whoami({ environment: "prod", client: { http: denied.http, env: CREDS } }),
|
|
110
124
|
).toMatchObject({ unresolved: { reason: "no-credentials" } });
|
|
111
125
|
|
|
112
126
|
const broken = recording(
|
|
113
127
|
"<ErrorResponse><Error><Code>ServiceUnavailable</Code><Message>try later</Message></Error></ErrorResponse>",
|
|
114
128
|
503,
|
|
115
129
|
);
|
|
116
|
-
const result = await
|
|
130
|
+
const result = await whoami({ environment: "prod", client: { http: broken.http, env: CREDS } });
|
|
117
131
|
expect(result).toMatchObject({ unresolved: { reason: "read-failed" } });
|
|
118
132
|
expect((result as { unresolved: { detail: string } }).unresolved.detail).toContain("ServiceUnavailable");
|
|
119
133
|
});
|
|
120
134
|
|
|
121
135
|
test("an answer with no Arn is read-failed, never an empty identity", async () => {
|
|
122
136
|
const { http } = recording("<GetCallerIdentityResponse><GetCallerIdentityResult/></GetCallerIdentityResponse>");
|
|
123
|
-
const result = await
|
|
137
|
+
const result = await whoami({ environment: "prod", client: { http, env: CREDS } });
|
|
124
138
|
expect(result).toMatchObject({ unresolved: { reason: "read-failed" } });
|
|
125
139
|
});
|
|
126
140
|
|
|
@@ -129,7 +143,7 @@ describe("aws describeIdentity (#1982)", () => {
|
|
|
129
143
|
// to us-east-1; it does not consult AWS_REGION. Reporting the shell's var
|
|
130
144
|
// here would describe a target no read uses.
|
|
131
145
|
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
132
|
-
const result = await
|
|
146
|
+
const result = await whoami({
|
|
133
147
|
environment: "prod",
|
|
134
148
|
client: { http, env: { ...CREDS, AWS_REGION: "ap-south-1" } },
|
|
135
149
|
});
|
|
@@ -140,7 +154,7 @@ describe("aws describeIdentity (#1982)", () => {
|
|
|
140
154
|
|
|
141
155
|
test("no credential material appears anywhere in the reported row", async () => {
|
|
142
156
|
const { http } = recording(CALLER_IDENTITY_XML);
|
|
143
|
-
const result = await
|
|
157
|
+
const result = await whoami({ environment: "prod", client: { http, env: CREDS } });
|
|
144
158
|
const serialized = JSON.stringify(result);
|
|
145
159
|
expect(serialized).not.toContain(CREDS.AWS_SECRET_ACCESS_KEY);
|
|
146
160
|
expect(serialized).not.toContain(CREDS.AWS_ACCESS_KEY_ID);
|
|
@@ -152,7 +166,7 @@ describe("whoami and the live read resolve the same target (#1982 acceptance)",
|
|
|
152
166
|
const env = { ...CREDS, AWS_ENDPOINT_URL: "http://localhost:4566" };
|
|
153
167
|
|
|
154
168
|
const identityHttp = recording(CALLER_IDENTITY_XML);
|
|
155
|
-
const identity = await
|
|
169
|
+
const identity = await whoami({
|
|
156
170
|
environment: "floci",
|
|
157
171
|
region: "eu-west-1",
|
|
158
172
|
client: { http: identityHttp.http, env },
|
|
@@ -170,7 +184,7 @@ describe("whoami and the live read resolve the same target (#1982 acceptance)",
|
|
|
170
184
|
|
|
171
185
|
test("against real AWS both resolve the same region, on each service's own host", async () => {
|
|
172
186
|
const identityHttp = recording(CALLER_IDENTITY_XML);
|
|
173
|
-
await
|
|
187
|
+
await whoami({
|
|
174
188
|
environment: "prod",
|
|
175
189
|
region: "eu-west-1",
|
|
176
190
|
client: { http: identityHttp.http, env: CREDS },
|
|
@@ -189,3 +203,274 @@ describe("whoami and the live read resolve the same target (#1982 acceptance)",
|
|
|
189
203
|
}
|
|
190
204
|
});
|
|
191
205
|
});
|
|
206
|
+
|
|
207
|
+
describe("whoami resolves AWS profiles the way the AWS CLI does (#3954)", () => {
|
|
208
|
+
const HOME = "/home/op";
|
|
209
|
+
const files = (map: Record<string, string>) => ({ home: HOME, readFile: (path: string) => map[path] });
|
|
210
|
+
|
|
211
|
+
test("a static-key profile named by AWS_PROFILE signs the call and is named as the source", async () => {
|
|
212
|
+
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
213
|
+
const result = await whoami({
|
|
214
|
+
environment: "prod",
|
|
215
|
+
client: { http, env: { AWS_PROFILE: "dev" } },
|
|
216
|
+
chain: files({
|
|
217
|
+
[`${HOME}/.aws/credentials`]: "[dev]\naws_access_key_id = AKIAPROFILEKEY00000\naws_secret_access_key = profilesecret\n",
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
expect(result).toMatchObject({
|
|
221
|
+
identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
|
|
222
|
+
source: "profile dev (static keys); chant default (no stacks[].region)",
|
|
223
|
+
});
|
|
224
|
+
expect(calls[0].headers.authorization).toContain("Credential=AKIAPROFILEKEY00000/");
|
|
225
|
+
expect(JSON.stringify(result)).not.toContain("profilesecret");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("an SSO-session profile is resolved through the cached token and the SSO portal", async () => {
|
|
229
|
+
const sts = recording(CALLER_IDENTITY_XML);
|
|
230
|
+
const portal: Array<{ url: string; headers: Record<string, string> }> = [];
|
|
231
|
+
const result = await whoami({
|
|
232
|
+
environment: "prod",
|
|
233
|
+
client: { http: sts.http, env: { AWS_PROFILE: "loom" } },
|
|
234
|
+
chain: {
|
|
235
|
+
...files({
|
|
236
|
+
[`${HOME}/.aws/config`]: [
|
|
237
|
+
"[profile loom]",
|
|
238
|
+
"sso_session = acme",
|
|
239
|
+
"sso_account_id = 491500000000",
|
|
240
|
+
"sso_role_name = Deployer",
|
|
241
|
+
"",
|
|
242
|
+
"[sso-session acme]",
|
|
243
|
+
"sso_start_url = https://acme.awsapps.com/start",
|
|
244
|
+
"sso_region = eu-west-1",
|
|
245
|
+
].join("\n"),
|
|
246
|
+
[`${HOME}/.aws/sso/cache/${sha1("acme")}.json`]: JSON.stringify({
|
|
247
|
+
accessToken: "sso-bearer",
|
|
248
|
+
expiresAt: "2030-01-01T00:00:00Z",
|
|
249
|
+
}),
|
|
250
|
+
}),
|
|
251
|
+
now: () => new Date("2026-10-12T00:00:00Z"),
|
|
252
|
+
http: (url, init) => {
|
|
253
|
+
portal.push({ url, headers: init.headers });
|
|
254
|
+
return Promise.resolve({
|
|
255
|
+
status: 200,
|
|
256
|
+
text: JSON.stringify({
|
|
257
|
+
roleCredentials: { accessKeyId: "ASIASSOKEY000000000", secretAccessKey: "ssosecret", sessionToken: "ssotoken" },
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
expect(portal).toEqual([
|
|
264
|
+
{
|
|
265
|
+
url: "https://portal.sso.eu-west-1.amazonaws.com/federation/credentials?account_id=491500000000&role_name=Deployer",
|
|
266
|
+
headers: { "x-amz-sso_bearer_token": "sso-bearer" },
|
|
267
|
+
},
|
|
268
|
+
]);
|
|
269
|
+
expect((result as { source: string }).source).toContain(
|
|
270
|
+
"profile loom (sso session acme), role Deployer in 491500000000",
|
|
271
|
+
);
|
|
272
|
+
expect(sts.calls[0].headers.authorization).toContain("Credential=ASIASSOKEY000000000/");
|
|
273
|
+
expect(sts.calls[0].headers["x-amz-security-token"]).toBe("ssotoken");
|
|
274
|
+
const serialized = JSON.stringify(result);
|
|
275
|
+
for (const secret of ["sso-bearer", "ssosecret", "ssotoken"]) expect(serialized).not.toContain(secret);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("an expired SSO session is no-credentials naming aws sso login --profile", async () => {
|
|
279
|
+
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
280
|
+
const portalCalls: string[] = [];
|
|
281
|
+
const result = await whoami({
|
|
282
|
+
environment: "prod",
|
|
283
|
+
client: { http, env: { AWS_PROFILE: "loom" } },
|
|
284
|
+
chain: {
|
|
285
|
+
...files({
|
|
286
|
+
[`${HOME}/.aws/config`]:
|
|
287
|
+
"[profile loom]\nsso_start_url = https://acme.awsapps.com/start\nsso_region = us-east-1\nsso_account_id = 491500000000\nsso_role_name = Deployer\n",
|
|
288
|
+
[`${HOME}/.aws/sso/cache/${sha1("https://acme.awsapps.com/start")}.json`]: JSON.stringify({
|
|
289
|
+
accessToken: "stale",
|
|
290
|
+
expiresAt: "2026-01-01T00:00:00Z",
|
|
291
|
+
}),
|
|
292
|
+
}),
|
|
293
|
+
now: () => new Date("2026-10-12T00:00:00Z"),
|
|
294
|
+
http: (url) => {
|
|
295
|
+
portalCalls.push(url);
|
|
296
|
+
return Promise.resolve({ status: 200, text: "{}" });
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
expect(result).toEqual({
|
|
301
|
+
unresolved: {
|
|
302
|
+
reason: "no-credentials",
|
|
303
|
+
detail: "profile loom: the SSO session has expired; run `aws sso login --profile loom`",
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
expect(portalCalls).toHaveLength(0);
|
|
307
|
+
expect(calls).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("a portal refusal of a cached session also names aws sso login", async () => {
|
|
311
|
+
const { http } = recording(CALLER_IDENTITY_XML);
|
|
312
|
+
const result = await whoami({
|
|
313
|
+
environment: "prod",
|
|
314
|
+
client: { http, env: {} },
|
|
315
|
+
chain: {
|
|
316
|
+
...files({
|
|
317
|
+
[`${HOME}/.aws/config`]:
|
|
318
|
+
"[default]\nsso_session = acme\nsso_account_id = 1\nsso_role_name = R\n[sso-session acme]\nsso_start_url = https://a/start\nsso_region = us-east-1\n",
|
|
319
|
+
[`${HOME}/.aws/sso/cache/${sha1("acme")}.json`]: JSON.stringify({
|
|
320
|
+
accessToken: "revoked",
|
|
321
|
+
expiresAt: "2030-01-01T00:00:00Z",
|
|
322
|
+
}),
|
|
323
|
+
}),
|
|
324
|
+
now: () => new Date("2026-10-12T00:00:00Z"),
|
|
325
|
+
http: () => Promise.resolve({ status: 401, text: '{"message":"Session token not found or invalid"}' }),
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
expect(result).toMatchObject({ unresolved: { reason: "no-credentials" } });
|
|
329
|
+
expect((result as { unresolved: { detail: string } }).unresolved.detail).toContain(
|
|
330
|
+
"run `aws sso login --profile default`",
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("an expired sso-session token with a refresh token is refreshed, not reported expired", async () => {
|
|
335
|
+
const sts = recording(CALLER_IDENTITY_XML);
|
|
336
|
+
const seen: string[] = [];
|
|
337
|
+
const result = await whoami({
|
|
338
|
+
environment: "prod",
|
|
339
|
+
client: { http: sts.http, env: { AWS_PROFILE: "loom" } },
|
|
340
|
+
chain: {
|
|
341
|
+
...files({
|
|
342
|
+
[`${HOME}/.aws/config`]:
|
|
343
|
+
"[profile loom]\nsso_session = acme\nsso_account_id = 491500000000\nsso_role_name = Deployer\n[sso-session acme]\nsso_start_url = https://a/start\nsso_region = us-east-1\n",
|
|
344
|
+
[`${HOME}/.aws/sso/cache/${sha1("acme")}.json`]: JSON.stringify({
|
|
345
|
+
accessToken: "stale",
|
|
346
|
+
expiresAt: "2026-01-01T00:00:00Z",
|
|
347
|
+
refreshToken: "refresh",
|
|
348
|
+
clientId: "cid",
|
|
349
|
+
clientSecret: "csecret",
|
|
350
|
+
registrationExpiresAt: "2030-01-01T00:00:00Z",
|
|
351
|
+
}),
|
|
352
|
+
}),
|
|
353
|
+
now: () => new Date("2026-10-12T00:00:00Z"),
|
|
354
|
+
http: (url, init) => {
|
|
355
|
+
seen.push(`${init.method} ${url}`);
|
|
356
|
+
if (url.startsWith("https://oidc.")) {
|
|
357
|
+
expect(JSON.parse(init.body ?? "{}")).toMatchObject({ grantType: "refresh_token", refreshToken: "refresh" });
|
|
358
|
+
return Promise.resolve({ status: 200, text: JSON.stringify({ accessToken: "fresh", expiresIn: 3600 }) });
|
|
359
|
+
}
|
|
360
|
+
expect(init.headers["x-amz-sso_bearer_token"]).toBe("fresh");
|
|
361
|
+
return Promise.resolve({
|
|
362
|
+
status: 200,
|
|
363
|
+
text: JSON.stringify({ roleCredentials: { accessKeyId: "ASIAX", secretAccessKey: "s", sessionToken: "t" } }),
|
|
364
|
+
});
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
expect(seen).toEqual([
|
|
369
|
+
"POST https://oidc.us-east-1.amazonaws.com/token",
|
|
370
|
+
"GET https://portal.sso.us-east-1.amazonaws.com/federation/credentials?account_id=491500000000&role_name=Deployer",
|
|
371
|
+
]);
|
|
372
|
+
expect(result).toMatchObject({ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci" });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("credential_process output signs the call; a failing process is no-credentials without echoing output", async () => {
|
|
376
|
+
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
377
|
+
const config = { [`${HOME}/.aws/config`]: "[profile vault]\ncredential_process = aws-vault exec ci --json\n" };
|
|
378
|
+
const ran: string[] = [];
|
|
379
|
+
const ok = await whoami({
|
|
380
|
+
environment: "prod",
|
|
381
|
+
client: { http, env: { AWS_PROFILE: "vault" } },
|
|
382
|
+
chain: {
|
|
383
|
+
...files(config),
|
|
384
|
+
runProcess: (command) => {
|
|
385
|
+
ran.push(command);
|
|
386
|
+
return Promise.resolve({
|
|
387
|
+
exitCode: 0,
|
|
388
|
+
stderr: "",
|
|
389
|
+
stdout: JSON.stringify({ Version: 1, AccessKeyId: "ASIAPROC00000000000", SecretAccessKey: "procsecret" }),
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
expect(ran).toEqual(["aws-vault exec ci --json"]);
|
|
395
|
+
expect((ok as { source: string }).source).toContain("profile vault (credential_process)");
|
|
396
|
+
expect(calls[0].headers.authorization).toContain("Credential=ASIAPROC00000000000/");
|
|
397
|
+
|
|
398
|
+
const bad = await whoami({
|
|
399
|
+
environment: "prod",
|
|
400
|
+
client: { http, env: { AWS_PROFILE: "vault" } },
|
|
401
|
+
chain: {
|
|
402
|
+
...files(config),
|
|
403
|
+
runProcess: () => Promise.resolve({ exitCode: 0, stderr: "", stdout: '{"AccessKeyId":"ASIALEAK"' }),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
expect(bad).toMatchObject({ unresolved: { reason: "no-credentials" } });
|
|
407
|
+
expect(JSON.stringify(bad)).not.toContain("ASIALEAK");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("a role_arn profile assumes the role with its source profile's keys", async () => {
|
|
411
|
+
const assumed = `<AssumeRoleResponse><AssumeRoleResult><Credentials>
|
|
412
|
+
<AccessKeyId>ASIAROLE00000000000</AccessKeyId><SecretAccessKey>rolesecret</SecretAccessKey>
|
|
413
|
+
<SessionToken>roletoken</SessionToken><Expiration>2030-01-01T00:00:00Z</Expiration>
|
|
414
|
+
</Credentials></AssumeRoleResult></AssumeRoleResponse>`;
|
|
415
|
+
const calls: Array<{ body: string; auth: string }> = [];
|
|
416
|
+
const http: AwsReadHttp = (_url, init) => {
|
|
417
|
+
calls.push({ body: init.body, auth: init.headers.authorization ?? "" });
|
|
418
|
+
return Promise.resolve({ status: 200, text: init.body.includes("AssumeRole") ? assumed : CALLER_IDENTITY_XML });
|
|
419
|
+
};
|
|
420
|
+
const result = await whoami({
|
|
421
|
+
environment: "prod",
|
|
422
|
+
client: { http, env: { AWS_PROFILE: "deploy" } },
|
|
423
|
+
chain: files({
|
|
424
|
+
[`${HOME}/.aws/config`]:
|
|
425
|
+
"[profile deploy]\nrole_arn = arn:aws:iam::491500000000:role/deploy\nsource_profile = base\nrole_session_name = ci\n",
|
|
426
|
+
[`${HOME}/.aws/credentials`]: "[base]\naws_access_key_id = AKIABASE00000000000\naws_secret_access_key = basesecret\n",
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
expect(calls).toHaveLength(2);
|
|
430
|
+
expect(calls[0].body).toContain("Action=AssumeRole");
|
|
431
|
+
expect(calls[0].body).toContain("RoleSessionName=ci");
|
|
432
|
+
expect(calls[0].auth).toContain("Credential=AKIABASE00000000000/");
|
|
433
|
+
expect(calls[1].auth).toContain("Credential=ASIAROLE00000000000/");
|
|
434
|
+
expect((result as { source: string }).source).toContain(
|
|
435
|
+
"profile deploy (role arn:aws:iam::491500000000:role/deploy via profile base (static keys))",
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("environment keys win over AWS_PROFILE, as in the AWS CLI, and the row says the profile was not used", async () => {
|
|
440
|
+
const { http, calls } = recording(CALLER_IDENTITY_XML);
|
|
441
|
+
const result = await whoami({
|
|
442
|
+
environment: "prod",
|
|
443
|
+
client: { http, env: { ...CREDS, AWS_PROFILE: "dev" } },
|
|
444
|
+
chain: files({ [`${HOME}/.aws/credentials`]: "[dev]\naws_access_key_id = AKIAOTHER\naws_secret_access_key = x\n" }),
|
|
445
|
+
});
|
|
446
|
+
expect((result as { source: string }).source).toContain("env AWS_ACCESS_KEY_ID; AWS_PROFILE=dev not used");
|
|
447
|
+
expect(calls[0].headers.authorization).toContain(`Credential=${CREDS.AWS_ACCESS_KEY_ID}/`);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("the default profile answers when AWS_PROFILE is unset", async () => {
|
|
451
|
+
const { http } = recording(CALLER_IDENTITY_XML);
|
|
452
|
+
const result = await whoami({
|
|
453
|
+
environment: "prod",
|
|
454
|
+
client: { http, env: {} },
|
|
455
|
+
chain: files({ [`${HOME}/.aws/credentials`]: "[default]\naws_access_key_id = AKIADEF\naws_secret_access_key = d\n" }),
|
|
456
|
+
});
|
|
457
|
+
expect((result as { source: string }).source).toContain("profile default (static keys)");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE move the files read", async () => {
|
|
461
|
+
const { http } = recording(CALLER_IDENTITY_XML);
|
|
462
|
+
const result = await whoami({
|
|
463
|
+
environment: "prod",
|
|
464
|
+
client: {
|
|
465
|
+
http,
|
|
466
|
+
env: { AWS_PROFILE: "x", AWS_CONFIG_FILE: "/etc/aws/config", AWS_SHARED_CREDENTIALS_FILE: "/etc/aws/creds" },
|
|
467
|
+
},
|
|
468
|
+
chain: files({ "/etc/aws/creds": "[x]\naws_access_key_id = AKIAX\naws_secret_access_key = s\n" }),
|
|
469
|
+
});
|
|
470
|
+
expect((result as { source: string }).source).toContain("profile x (static keys)");
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
function sha1(value: string): string {
|
|
475
|
+
return createHash("sha1").update(value).digest("hex");
|
|
476
|
+
}
|
package/src/caller-identity.ts
CHANGED
|
@@ -20,12 +20,24 @@
|
|
|
20
20
|
*
|
|
21
21
|
* ## Credentials
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* Resolved the way the AWS CLI resolves them (#3954), by
|
|
24
|
+
* `./api/credential-chain.ts`: environment keys, then the profile
|
|
25
|
+
* `AWS_PROFILE` names (or `default`) from the shared config and credentials
|
|
26
|
+
* files, through static keys, `role_arn`, SSO sessions and
|
|
27
|
+
* `credential_process`. Before #3954 only the environment keys were read, so
|
|
28
|
+
* `AWS_PROFILE=dev` on an SSO session answered "no identity is configured"
|
|
29
|
+
* while `aws sts get-caller-identity` answered with a principal. An expired
|
|
30
|
+
* SSO session is `no-credentials` naming `aws sso login --profile <p>`.
|
|
31
|
+
*
|
|
32
|
+
* The native read transport still signs with environment keys only
|
|
33
|
+
* (`resolveCredentials` in ./api/sigv4.ts; #3958). So a profile-sourced row says
|
|
34
|
+
* what the CLI, and anything shelling out to it, would act as; it does not
|
|
35
|
+
* yet say what `describeResources` signs with. The `source` column names the
|
|
36
|
+
* profile, so the difference is visible.
|
|
37
|
+
*
|
|
38
|
+
* Against an endpoint override the chain is not walked: the emulator call is
|
|
39
|
+
* unsigned whatever resolves, and a whoami against Floci should not start an
|
|
40
|
+
* SSO refresh or a `credential_process`.
|
|
29
41
|
*
|
|
30
42
|
* No credential value is ever returned. The identity is the principal ARN the
|
|
31
43
|
* service itself answers with, and the scope is an account and a region.
|
|
@@ -39,7 +51,7 @@ import {
|
|
|
39
51
|
serviceUrl,
|
|
40
52
|
type AwsReadClientOptions,
|
|
41
53
|
} from "./api/read-client";
|
|
42
|
-
import {
|
|
54
|
+
import { resolveCredentialChain, type CredentialChainOptions } from "./api/credential-chain";
|
|
43
55
|
|
|
44
56
|
/** The region the read transport falls back to when no stack declares one. */
|
|
45
57
|
const DEFAULT_REGION = "us-east-1";
|
|
@@ -62,9 +74,9 @@ function regionSource(declared: string | undefined, env: Record<string, string |
|
|
|
62
74
|
return "chant default (no stacks[].region)";
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
/**
|
|
66
|
-
function
|
|
67
|
-
if (!
|
|
77
|
+
/** The environment-key label, used against an endpoint override where the chain is not walked. */
|
|
78
|
+
function envCredentialLabel(env: Record<string, string | undefined>): string | undefined {
|
|
79
|
+
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) return undefined;
|
|
68
80
|
return env.AWS_SESSION_TOKEN
|
|
69
81
|
? "env AWS_ACCESS_KEY_ID + AWS_SESSION_TOKEN"
|
|
70
82
|
: "env AWS_ACCESS_KEY_ID";
|
|
@@ -73,6 +85,12 @@ function credentialSource(env: Record<string, string | undefined>): string | und
|
|
|
73
85
|
/** Options this module needs beyond the contract — the injectable transport, for tests. */
|
|
74
86
|
export interface CallerIdentityOptions extends DescribeIdentityOptions {
|
|
75
87
|
client?: AwsReadClientOptions;
|
|
88
|
+
/**
|
|
89
|
+
* The credential chain's seams: shared-file reads, the SSO and
|
|
90
|
+
* `credential_process` transports, the clock. Its `env` defaults to the
|
|
91
|
+
* client's, and its STS transport to the client's `http`.
|
|
92
|
+
*/
|
|
93
|
+
chain?: CredentialChainOptions;
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
/**
|
|
@@ -91,19 +109,23 @@ export async function describeIdentity(options: CallerIdentityOptions): Promise<
|
|
|
91
109
|
const region = client.region ?? DEFAULT_REGION;
|
|
92
110
|
const endpoint = serviceUrl("sts", override, client.region);
|
|
93
111
|
|
|
94
|
-
const credential = credentialSource(env);
|
|
95
112
|
// Against an emulator the call is unsigned by design (Floci verifies no
|
|
96
113
|
// signature), so an absent credential is not yet a refusal — the emulator
|
|
97
|
-
// answers with the identity it models
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
// answers with the identity it models — and the chain is not walked.
|
|
115
|
+
let credential: string | undefined;
|
|
116
|
+
if (override) {
|
|
117
|
+
credential = envCredentialLabel(env);
|
|
118
|
+
} else if (client.credentials) {
|
|
119
|
+
credential = "credentials injected by the caller";
|
|
120
|
+
} else {
|
|
121
|
+
const chain = await resolveCredentialChain({
|
|
122
|
+
env,
|
|
123
|
+
...options.chain,
|
|
124
|
+
sts: { ...(client.http ? { http: client.http } : {}), ...options.chain?.sts, env },
|
|
125
|
+
});
|
|
126
|
+
if ("unresolved" in chain) return { unresolved: chain.unresolved };
|
|
127
|
+
credential = chain.resolved.source;
|
|
128
|
+
client.credentials = chain.resolved.credentials;
|
|
107
129
|
}
|
|
108
130
|
|
|
109
131
|
let identity;
|
package/src/codegen/generate.ts
CHANGED
|
@@ -27,6 +27,15 @@ import {
|
|
|
27
27
|
type EnumOverlayApplication,
|
|
28
28
|
type EnumOverlayEntry,
|
|
29
29
|
} from "./enum-overlay";
|
|
30
|
+
import {
|
|
31
|
+
applyPropertyOverlay,
|
|
32
|
+
assertPropertyOverlayCoverage,
|
|
33
|
+
propertyOverlayByType,
|
|
34
|
+
propertyOverlayEntries,
|
|
35
|
+
redundantPropertyOverlayWarnings,
|
|
36
|
+
type PropertyOverlayApplication,
|
|
37
|
+
type PropertyOverlayEntry,
|
|
38
|
+
} from "./property-overlay";
|
|
30
39
|
import { samResources } from "./sam";
|
|
31
40
|
import { fallbackResources } from "./fallback";
|
|
32
41
|
import { NamingStrategy, publishedNames, propertyTypeName, extractDefName } from "./naming";
|
|
@@ -56,6 +65,9 @@ let awsOverlay: Map<string, EnumOverlayEntry[]> = new Map();
|
|
|
56
65
|
let awsOverlayApplications: EnumOverlayApplication[] = [];
|
|
57
66
|
let awsOverlaySeenTypes = new Set<string>();
|
|
58
67
|
let awsOverlayStrict = false;
|
|
68
|
+
/** Backfilled properties (chant #3953), reset per `generate()` call; strictness and seen types are shared with the enum overlay. */
|
|
69
|
+
let awsPropertyOverlay: Map<string, PropertyOverlayEntry[]> = new Map();
|
|
70
|
+
let awsPropertyOverlayApplications: PropertyOverlayApplication[] = [];
|
|
59
71
|
|
|
60
72
|
const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
61
73
|
fetchSchemas: async (opts) => {
|
|
@@ -69,8 +81,11 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
|
69
81
|
|
|
70
82
|
parseSchema: (typeName, data) => {
|
|
71
83
|
awsOverlaySeenTypes.add(typeName);
|
|
84
|
+
// Properties first, so an enum overlay entry could name a backfilled one.
|
|
85
|
+
const backfilled = applyPropertyOverlay(data, awsPropertyOverlay.get(typeName) ?? []);
|
|
86
|
+
awsPropertyOverlayApplications.push(...backfilled.applications);
|
|
72
87
|
const entries = awsOverlay.get(typeName) ?? [];
|
|
73
|
-
const overlaid = applyEnumOverlay(typeName, data, entries, { strict: awsOverlayStrict });
|
|
88
|
+
const overlaid = applyEnumOverlay(typeName, backfilled.data, entries, { strict: awsOverlayStrict });
|
|
74
89
|
awsOverlayApplications.push(...overlaid.applications);
|
|
75
90
|
const result = parseCFNSchema(overlaid.data);
|
|
76
91
|
if (!result.resource.typeName) return null;
|
|
@@ -142,6 +157,11 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
|
142
157
|
log(`Applied ${applied} curated enum overlay entries`);
|
|
143
158
|
warnings.push(...redundantOverlayWarnings(awsOverlayApplications));
|
|
144
159
|
|
|
160
|
+
assertPropertyOverlayCoverage(propertyOverlayEntries(), awsOverlaySeenTypes, awsOverlayStrict);
|
|
161
|
+
const backfilled = awsPropertyOverlayApplications.filter((a) => a.outcome === "applied").length;
|
|
162
|
+
log(`Backfilled ${backfilled} properties the pinned spec predates`);
|
|
163
|
+
warnings.push(...redundantPropertyOverlayWarnings(awsPropertyOverlayApplications));
|
|
164
|
+
|
|
145
165
|
log(`Total: ${results.length} resource schemas`);
|
|
146
166
|
return { results, warnings };
|
|
147
167
|
},
|
|
@@ -174,6 +194,8 @@ export async function generate(opts: GenerateOptions = {}): Promise<GenerateResu
|
|
|
174
194
|
awsOverlay = enumOverlayByType();
|
|
175
195
|
awsOverlayApplications = [];
|
|
176
196
|
awsOverlaySeenTypes = new Set();
|
|
197
|
+
awsPropertyOverlay = propertyOverlayByType();
|
|
198
|
+
awsPropertyOverlayApplications = [];
|
|
177
199
|
// A fixture set is a trimmed subset, so an entry it never reaches is
|
|
178
200
|
// expected; a run over the real zip has no such excuse.
|
|
179
201
|
awsOverlayStrict = !opts.schemaSource;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Properties CloudFormation has shipped since the pinned Registry spec (src/spec/pin.ts) was accepted (chant #3953). Hand-maintained, merged into the raw schema at codegen time by src/codegen/property-overlay.ts. Each entry is copied from the live Registry schema; retire it when a pin bump (#3960) brings the property in; generation warns when it does.",
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"type": "AWS::BedrockAgentCore::Runtime",
|
|
6
|
+
"property": "PlatformVersion",
|
|
7
|
+
"issue": 3953,
|
|
8
|
+
"source": "https://schema.cloudformation.us-east-1.amazonaws.com/CloudformationSchema.zip (aws-bedrockagentcore-runtime.json)",
|
|
9
|
+
"reviewed": "2026-10-12",
|
|
10
|
+
"schema": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "The version of the runtime platform",
|
|
13
|
+
"minLength": 1,
|
|
14
|
+
"maxLength": 128,
|
|
15
|
+
"pattern": "^[^\\s]+$"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyPropertyOverlay,
|
|
4
|
+
assertPropertyOverlayCoverage,
|
|
5
|
+
propertyOverlayEntries,
|
|
6
|
+
redundantPropertyOverlayWarnings,
|
|
7
|
+
type PropertyOverlayEntry,
|
|
8
|
+
} from "./property-overlay";
|
|
9
|
+
|
|
10
|
+
const entry = (over: Partial<PropertyOverlayEntry> = {}): PropertyOverlayEntry => ({
|
|
11
|
+
type: "AWS::Test::Resource",
|
|
12
|
+
property: "Version",
|
|
13
|
+
issue: 1,
|
|
14
|
+
source: "https://example.invalid",
|
|
15
|
+
reviewed: "2026-10-12",
|
|
16
|
+
schema: { type: "string", maxLength: 8 },
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const schema = (properties: Record<string, unknown> = { Name: { type: "string" } }) =>
|
|
21
|
+
JSON.stringify({ typeName: "AWS::Test::Resource", properties, additionalProperties: false });
|
|
22
|
+
|
|
23
|
+
describe("applyPropertyOverlay", () => {
|
|
24
|
+
test("adds a property the schema lacks, leaving the rest alone", () => {
|
|
25
|
+
const { data, applications } = applyPropertyOverlay(schema(), [entry()]);
|
|
26
|
+
expect(applications.map((a) => a.outcome)).toEqual(["applied"]);
|
|
27
|
+
const doc = JSON.parse(data as string);
|
|
28
|
+
expect(doc.properties.Version).toEqual({ type: "string", maxLength: 8 });
|
|
29
|
+
expect(doc.properties.Name).toEqual({ type: "string" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("the spec wins once it declares the property", () => {
|
|
33
|
+
const upstream = schema({ Version: { type: "string", maxLength: 128 } });
|
|
34
|
+
const { data, applications } = applyPropertyOverlay(upstream, [entry()]);
|
|
35
|
+
expect(data).toBe(upstream);
|
|
36
|
+
expect(applications.map((a) => a.outcome)).toEqual(["redundant"]);
|
|
37
|
+
expect(redundantPropertyOverlayWarnings(applications)).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("keeps the input's encoding", () => {
|
|
41
|
+
const { data } = applyPropertyOverlay(Buffer.from(schema()), [entry()]);
|
|
42
|
+
expect(Buffer.isBuffer(data)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("assertPropertyOverlayCoverage", () => {
|
|
47
|
+
test("a strict run refuses an entry on a type it never saw", () => {
|
|
48
|
+
expect(() => assertPropertyOverlayCoverage([entry()], new Set(), true)).toThrow(/AWS::Test::Resource/);
|
|
49
|
+
expect(() => assertPropertyOverlayCoverage([entry()], new Set(), false)).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("property-overlay.json", () => {
|
|
54
|
+
test("every entry names a top-level property and a schema with a type", () => {
|
|
55
|
+
for (const e of propertyOverlayEntries()) {
|
|
56
|
+
expect(e.type).toMatch(/^AWS::\w+::\w+$/);
|
|
57
|
+
expect(e.property).toMatch(/^[A-Z]\w*$/);
|
|
58
|
+
expect(typeof e.schema.type).toBe("string");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|