@intentius/chant-lexicon-cedar 0.44.10 → 0.44.13

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.
@@ -14,14 +14,37 @@
14
14
  *
15
15
  * ## Signing
16
16
  *
17
- * Requests are unsigned, exactly like the aws lexicon's read client. Real AWS
18
- * rejects them; an emulator with an endpoint override does not. This is
19
- * therefore an emulator-and-test transport today, and the honest consequence is
20
- * encoded in {@link credentialsAvailable}: with no credentials and no endpoint
21
- * override, the observation reports every entity NOT-OBSERVED with
22
- * `no-credentials` rather than issuing a request that will fail. The signed
23
- * path lands when SigV4 lands in `lexicons/aws/src/api/read-client.ts`, which
24
- * is where it belongs — one implementation, not two.
17
+ * SigV4 lives once, in `lexicons/aws/src/api/sigv4.ts` (#1686). This module
18
+ * does not import it. A cedar → aws dependency edge would make the vendor-
19
+ * neutral lexicon unbuildable without the AWS one, which is the same rule that
20
+ * kept `src/avp/embed.ts` free of the aws lexicon: the seam is the data shape.
21
+ * So the signer arrives as a function on {@link AvpClientOptions}, typed here
22
+ * structurally against what the aws lexicon exports, and a project holding both
23
+ * lexicons wires them in one line:
24
+ *
25
+ * ```ts
26
+ * import { signRequest } from "@intentius/chant-lexicon-aws";
27
+ * import { describeAvpResources } from "@intentius/chant-lexicon-cedar";
28
+ *
29
+ * await describeAvpResources({
30
+ * environment,
31
+ * entityNames,
32
+ * entities,
33
+ * client: { region: "us-west-2", signer: signRequest },
34
+ * });
35
+ * ```
36
+ *
37
+ * Three cases go out unsigned, byte for byte as they did before signing
38
+ * existed — the credential scope of {@link regionScope} and nothing more:
39
+ *
40
+ * - **No signer.** The default, and the reason `credentialsAvailable` still
41
+ * gates the readers: a caller that wires nothing in is still an
42
+ * emulator-and-test transport, and real AWS still rejects it.
43
+ * - **No credentials.** A signer with nothing to sign with does not run.
44
+ * - **An endpoint override.** An emulator does not verify signatures, and
45
+ * signing against one would make every local lane need credentials to read
46
+ * what it just deployed. `signEndpointOverride` opts back in for an
47
+ * override that *is* real AWS — a VPC endpoint, a signing proxy.
25
48
  */
26
49
  /** Injectable HTTP, mirroring `AwsReadHttp` in the aws lexicon so tests avoid the network. */
27
50
  export type AvpHttp = (url: string, init: {
@@ -40,6 +63,38 @@ export declare class AvpReadError extends Error {
40
63
  /** The service's own error code (`ResourceNotFoundException`, `AccessDeniedException`, …). */
41
64
  code?: string | undefined);
42
65
  }
66
+ /**
67
+ * A resolved credential set — the aws lexicon's `AwsCredentials`, restated so
68
+ * that this file compiles without it. `sessionToken` is present for STS/role
69
+ * credentials.
70
+ */
71
+ export interface AvpCredentials {
72
+ accessKeyId: string;
73
+ secretAccessKey: string;
74
+ sessionToken?: string;
75
+ }
76
+ /** A function that decides what to sign with; `undefined` means "nothing to". */
77
+ export type AvpCredentialResolver = () => AvpCredentials | undefined;
78
+ /** Either literal credentials or a resolver for them. */
79
+ export type AvpCredentialSource = AvpCredentials | AvpCredentialResolver;
80
+ /** One request to sign — the aws lexicon's `SigV4Request`, restated. */
81
+ export interface AvpSignableRequest {
82
+ method: string;
83
+ url: string;
84
+ headers: Record<string, string>;
85
+ body: string;
86
+ service: string;
87
+ region: string;
88
+ credentials: AvpCredentials;
89
+ /** Signing clock. Injected by tests; otherwise now. */
90
+ now?: Date;
91
+ }
92
+ /**
93
+ * The signing seam. `signRequest` from `@intentius/chant-lexicon-aws` satisfies
94
+ * this as it stands — the shapes above are its own, restated rather than
95
+ * imported, so cedar keeps no edge to the aws lexicon.
96
+ */
97
+ export type AvpSigner = (request: AvpSignableRequest) => Record<string, string>;
43
98
  export interface AvpClientOptions {
44
99
  /** Endpoint override (an emulator, or a VPC endpoint). Omit for real AWS hosts. */
45
100
  endpoint?: string;
@@ -49,7 +104,29 @@ export interface AvpClientOptions {
49
104
  signal?: AbortSignal;
50
105
  /** Environment to read credentials from. Defaults to `process.env`; injectable for tests. */
51
106
  env?: Record<string, string | undefined>;
107
+ /** SigV4, injected. Omitted, every request goes out unsigned as it always did. */
108
+ signer?: AvpSigner;
109
+ /**
110
+ * What to sign with: literal credentials, or a resolver that decides. Omitted,
111
+ * the environment answers; when it has nothing, no signature is produced.
112
+ */
113
+ credentials?: AvpCredentialSource;
114
+ /** Sign even against an endpoint override — for an override that is real AWS. */
115
+ signEndpointOverride?: boolean;
116
+ /** Signing clock. Injected by tests so a signature is reproducible. */
117
+ now?: Date;
52
118
  }
119
+ /**
120
+ * Credentials for a request: explicit → environment → absent.
121
+ *
122
+ * The same rule the aws lexicon's `resolveCredentials` applies, and for the same
123
+ * reasons: an injected resolver is authoritative and its refusal is not
124
+ * second-guessed against the environment, and a half-set environment is absent
125
+ * rather than a signature that cannot verify. A caller with a profile file, IMDS
126
+ * or a container credential endpoint resolves those itself and passes the result
127
+ * in — that is what the resolver form is for.
128
+ */
129
+ export declare function resolveAvpCredentials(source?: AvpCredentialSource, env?: Record<string, string | undefined>): AvpCredentials | undefined;
53
130
  /** Service host for AVP, honouring an endpoint override. */
54
131
  export declare function avpUrl(endpoint?: string, region?: string): string;
55
132
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/avp/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/avp/client.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "31dbc6f8b54821086945282b3bb91b412af4692a167a827265f0a092e45eb5c2",
4
+ "manifest.json": "52ec48ea8657db4733de532b7c149bcf76853414a2118b013309c32252b458cb",
5
5
  "meta.json": "b218779260169a193882aeee76e5ac559588f1682657a001fb0982cffb5843fc",
6
6
  "types/index.d.ts": "9e53a1cb3c9ee38f9de2f3c0db9cc28a0b67c3649be410c341e2451b9b0076cd",
7
7
  "rules/policy-shape.ts": "1654730d188b7163dbad3051c75a562198a96eda6d36ce35701f2b729bf45194",
@@ -30,5 +30,5 @@
30
30
  "skills/chant-cedar-meta-policy.md": "050b310a87196a48c827f1129698e4c6bbcd96dc963782e22bf1a640bbc20a1a",
31
31
  "skills/chant-cedar-dogwood.md": "fc8c9a2ea9cd7bf87037c156b0b419397d46fbf5cd201c09be721249ac91b8da"
32
32
  },
33
- "composite": "dc7485b6a8f3831e6564a5ed5402ba4e0362065da05b54f481f29e3aabdba1a9"
33
+ "composite": "9b4af2b1da25658f48c94123f1cebdb5d3a391851a9fd263342891073eccbd45"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cedar",
3
- "version": "0.44.10",
3
+ "version": "0.44.13",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Cedar",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-cedar",
3
- "version": "0.44.10",
3
+ "version": "0.44.13",
4
4
  "description": "Cedar lexicon for chant — typed authoring for Cedar authorization policies",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -74,7 +74,7 @@
74
74
  },
75
75
  "peerDependencies": {
76
76
  "zod": "^4.3.6",
77
- "@intentius/chant": "^0.44.10",
77
+ "@intentius/chant": "^0.44.13",
78
78
  "typescript": "^5.9.3"
79
79
  }
80
80
  }
@@ -94,6 +94,44 @@ for a lexicon that reported nothing. The obligation is still real for anyone ext
94
94
  this file: a new read path that cannot see the description must not be added to
95
95
  `reads`.
96
96
 
97
+ ## Reading the store: the signing seam
98
+
99
+ The reader was an emulator-and-test transport when #1652 landed, because its requests
100
+ were unsigned and real AWS rejects those. SigV4 now exists in the aws lexicon
101
+ (`lexicons/aws/src/api/sigv4.ts`, chant #1686) and `src/avp/client.ts` uses it — through
102
+ a function on `AvpClientOptions`, not through an import.
103
+
104
+ The reason is the same one that kept `src/avp/embed.ts` free of the aws lexicon: a
105
+ cedar → aws dependency edge would make the vendor-neutral lexicon unbuildable without
106
+ the AWS one, for the sake of a transport most Cedar deployments never use. So
107
+ `AvpSigner`, `AvpCredentials` and `AvpSignableRequest` restate the aws lexicon's own
108
+ shapes, and `signRequest` satisfies `AvpSigner` as it stands. A project that has both
109
+ lexicons installed wires them where it already builds the client options:
110
+
111
+ ```ts
112
+ import { signRequest } from "@intentius/chant-lexicon-aws";
113
+ import { describeAvpResources } from "@intentius/chant-lexicon-cedar";
114
+
115
+ await describeAvpResources({
116
+ environment,
117
+ entityNames,
118
+ entities,
119
+ client: { region: "us-west-2", signer: signRequest },
120
+ });
121
+ ```
122
+
123
+ The cost of restating rather than importing is that a shape change in the aws lexicon
124
+ shows up as a type error in the consumer that wires the two, not here. That is the
125
+ trade the decoupling buys, and it is why the two files name each other in prose.
126
+
127
+ Unsigned stays the default, and three cases stay unsigned regardless: no signer wired
128
+ in, no credentials resolved, and an endpoint override without `signEndpointOverride`
129
+ (an emulator does not verify signatures, and signing against one would make every local
130
+ lane need credentials to read what it just deployed). `credentialsAvailable` therefore
131
+ still gates the readers exactly as before — a caller that wires nothing in gets every
132
+ entity NOT-OBSERVED with `no-credentials` rather than a request that was never going to
133
+ work.
134
+
97
135
  ## Alternatives rejected
98
136
 
99
137
  **Store-level tags as the only channel.** The epic's provisional position. Rejected
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The AVP transport's signing decision (#1686 follow-up).
3
+ *
4
+ * `lexicons/aws/src/api/sigv4.test.ts` proves a SigV4 signature is correct
5
+ * against AWS's own vectors. Nothing here re-proves that — cedar does not hold
6
+ * the implementation and deliberately does not import it. What these cover is
7
+ * the decision the client makes: whether a signer runs at all, what request it
8
+ * is handed, and that the unsigned path is unchanged when one is not wired in.
9
+ */
10
+ import { createHmac } from "node:crypto";
11
+ import { describe, expect, test } from "vitest";
12
+ import {
13
+ avpCall,
14
+ resolveAvpCredentials,
15
+ type AvpClientOptions,
16
+ type AvpHttp,
17
+ type AvpSignableRequest,
18
+ type AvpSigner,
19
+ } from "./client";
20
+
21
+ const credentials = { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" };
22
+ const now = new Date("2015-08-30T12:36:00Z");
23
+
24
+ interface Call {
25
+ url: string;
26
+ headers: Record<string, string>;
27
+ body: string;
28
+ }
29
+
30
+ /** An {@link AvpHttp} that records what it was given and answers an empty object. */
31
+ function recording(): { http: AvpHttp; calls: Call[] } {
32
+ const calls: Call[] = [];
33
+ const http: AvpHttp = async (url, init) => {
34
+ calls.push({ url, headers: init.headers, body: init.body });
35
+ return { status: 200, text: "{}" };
36
+ };
37
+ return { http, calls };
38
+ }
39
+
40
+ /**
41
+ * A stand-in for the aws lexicon's `signRequest`, with its shape and its
42
+ * observable habits: it emits `x-amz-date`, a session token when there is one,
43
+ * and an `Authorization` header of the real form, and it never emits `host`.
44
+ * The signature is an HMAC over the body — deterministic, and enough to tell a
45
+ * signed request from the placeholder.
46
+ */
47
+ function stubSigner(): { signer: AvpSigner; signed: AvpSignableRequest[] } {
48
+ const signed: AvpSignableRequest[] = [];
49
+ const signer: AvpSigner = (request) => {
50
+ signed.push(request);
51
+ const timestamp = (request.now ?? new Date()).toISOString().replace(/[:-]|\.\d{3}/g, "");
52
+ const day = timestamp.slice(0, 8);
53
+ const scope = `${day}/${request.region}/${request.service}/aws4_request`;
54
+ const signature = createHmac("sha256", request.credentials.secretAccessKey).update(request.body).digest("hex");
55
+ return {
56
+ ...request.headers,
57
+ "x-amz-date": timestamp,
58
+ ...(request.credentials.sessionToken ? { "x-amz-security-token": request.credentials.sessionToken } : {}),
59
+ authorization:
60
+ `AWS4-HMAC-SHA256 Credential=${request.credentials.accessKeyId}/${scope}, ` +
61
+ `SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=${signature}`,
62
+ };
63
+ };
64
+ return { signer, signed };
65
+ }
66
+
67
+ describe("SigV4 on the AVP read path", () => {
68
+ test("a signer, credentials and a real host produce a signed request", async () => {
69
+ const { http, calls } = recording();
70
+ const { signer, signed } = stubSigner();
71
+ await avpCall("GetPolicyStore", { policyStoreId: "PS-abc" }, {
72
+ region: "us-west-2",
73
+ credentials,
74
+ signer,
75
+ now,
76
+ http,
77
+ env: {},
78
+ });
79
+
80
+ const auth = calls[0]!.headers.authorization!;
81
+ expect(auth).toContain("Credential=AKIDEXAMPLE/20150830/us-west-2/verifiedpermissions/aws4_request");
82
+ expect(auth).not.toContain("Signature=unsigned");
83
+ expect(auth).toMatch(/Signature=[0-9a-f]{64}$/);
84
+ expect(calls[0]!.headers["x-amz-date"]).toBe("20150830T123600Z");
85
+ // `host` is signed but never emitted: fetch computes it and forbids the override.
86
+ expect(calls[0]!.headers.host).toBeUndefined();
87
+
88
+ // What the signer was handed is a complete SigV4 request for this call.
89
+ expect(signed).toHaveLength(1);
90
+ expect(signed[0]).toMatchObject({
91
+ method: "POST",
92
+ url: "https://verifiedpermissions.us-west-2.amazonaws.com/",
93
+ service: "verifiedpermissions",
94
+ region: "us-west-2",
95
+ body: JSON.stringify({ policyStoreId: "PS-abc" }),
96
+ credentials,
97
+ now,
98
+ });
99
+ expect(signed[0]!.headers).toEqual({
100
+ "content-type": "application/x-amz-json-1.0",
101
+ "x-amz-target": "VerifiedPermissions.GetPolicyStore",
102
+ });
103
+ // The body the signer hashed is the body that went out.
104
+ expect(calls[0]!.body).toBe(signed[0]!.body);
105
+ });
106
+
107
+ test("the signer's headers are the wire headers — no placeholder scope survives", async () => {
108
+ const { http, calls } = recording();
109
+ const { signer } = stubSigner();
110
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, {
111
+ region: "us-west-2",
112
+ credentials,
113
+ signer,
114
+ now,
115
+ http,
116
+ env: { AWS_ACCESS_KEY_ID: "AKIDENV" },
117
+ });
118
+ expect(Object.keys(calls[0]!.headers).sort()).toEqual([
119
+ "authorization",
120
+ "content-type",
121
+ "x-amz-date",
122
+ "x-amz-target",
123
+ ]);
124
+ });
125
+
126
+ test("a session token rides along when the credentials are temporary", async () => {
127
+ const { http, calls } = recording();
128
+ const { signer } = stubSigner();
129
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, {
130
+ region: "eu-west-1",
131
+ signer,
132
+ now,
133
+ http,
134
+ env: { AWS_ACCESS_KEY_ID: "AKIDENV", AWS_SECRET_ACCESS_KEY: "s", AWS_SESSION_TOKEN: "tok" },
135
+ });
136
+ expect(calls[0]!.headers.authorization).toContain("Credential=AKIDENV/20150830/eu-west-1/verifiedpermissions/");
137
+ expect(calls[0]!.headers["x-amz-security-token"]).toBe("tok");
138
+ });
139
+
140
+ test("signing without a named region falls back to the same default the host does", async () => {
141
+ const { http, calls } = recording();
142
+ const { signer, signed } = stubSigner();
143
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, { credentials, signer, now, http, env: {} });
144
+ expect(calls[0]!.url).toBe("https://verifiedpermissions.us-east-1.amazonaws.com/");
145
+ expect(signed[0]!.region).toBe("us-east-1");
146
+ expect(calls[0]!.headers.authorization).toContain("/20150830/us-east-1/verifiedpermissions/aws4_request");
147
+ });
148
+ });
149
+
150
+ describe("the unsigned path, unchanged", () => {
151
+ /** The headers one `ListPolicies` goes out with under `options`. */
152
+ async function headersFor(options: AvpClientOptions): Promise<Record<string, string>> {
153
+ const { http, calls } = recording();
154
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, { ...options, http });
155
+ return calls[0]!.headers;
156
+ }
157
+
158
+ test("no signer leaves the transport exactly as it was", async () => {
159
+ const headers = await headersFor({ region: "us-west-2", env: {} });
160
+ expect(Object.keys(headers).sort()).toEqual(["authorization", "content-type", "x-amz-target"]);
161
+ expect(headers["content-type"]).toBe("application/x-amz-json-1.0");
162
+ expect(headers["x-amz-target"]).toBe("VerifiedPermissions.ListPolicies");
163
+ expect(headers.authorization).toContain("Credential=chant/");
164
+ expect(headers.authorization).toContain("/us-west-2/verifiedpermissions/aws4_request");
165
+ expect(headers.authorization).toContain("SignedHeaders=host, Signature=unsigned");
166
+ expect(headers["x-amz-date"]).toBeUndefined();
167
+ });
168
+
169
+ test("a signer with nothing to sign with is byte-identical to no signer", async () => {
170
+ const { signer, signed } = stubSigner();
171
+ const unsigned = await headersFor({ region: "us-west-2", env: {} });
172
+ const withSigner = await headersFor({ region: "us-west-2", signer, env: {} });
173
+ expect(withSigner).toEqual(unsigned);
174
+ expect(signed).toHaveLength(0);
175
+ });
176
+
177
+ test("a resolver that declines is respected, and the environment is not consulted behind it", async () => {
178
+ const { signer, signed } = stubSigner();
179
+ const headers = await headersFor({
180
+ region: "us-west-2",
181
+ signer,
182
+ credentials: () => undefined,
183
+ env: { AWS_ACCESS_KEY_ID: "AKIDENV", AWS_SECRET_ACCESS_KEY: "s" },
184
+ });
185
+ expect(headers.authorization).toContain("Signature=unsigned");
186
+ expect(signed).toHaveLength(0);
187
+ });
188
+
189
+ test("no region still means no authorization header at all", async () => {
190
+ const headers = await headersFor({ env: {} });
191
+ expect(Object.keys(headers).sort()).toEqual(["content-type", "x-amz-target"]);
192
+ });
193
+ });
194
+
195
+ describe("endpoint overrides", () => {
196
+ test("an override is not signed, so the emulator lanes need no credentials", async () => {
197
+ const { http, calls } = recording();
198
+ const { signer, signed } = stubSigner();
199
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, {
200
+ endpoint: "http://localhost:4566",
201
+ region: "us-west-2",
202
+ credentials,
203
+ signer,
204
+ http,
205
+ env: {},
206
+ });
207
+ expect(calls[0]!.url).toBe("http://localhost:4566/");
208
+ expect(calls[0]!.headers.authorization).toContain("Signature=unsigned");
209
+ expect(calls[0]!.headers["x-amz-date"]).toBeUndefined();
210
+ expect(signed).toHaveLength(0);
211
+ });
212
+
213
+ test("AWS_ENDPOINT_URL counts as an override, the same as the option does", async () => {
214
+ const { http, calls } = recording();
215
+ const { signer, signed } = stubSigner();
216
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, {
217
+ region: "us-west-2",
218
+ credentials,
219
+ signer,
220
+ http,
221
+ env: { AWS_ENDPOINT_URL: "http://localhost:4566" },
222
+ });
223
+ expect(calls[0]!.url).toBe("http://localhost:4566/");
224
+ expect(calls[0]!.headers.authorization).toContain("Signature=unsigned");
225
+ expect(signed).toHaveLength(0);
226
+ });
227
+
228
+ test("signEndpointOverride signs one anyway — for an override that is real AWS", async () => {
229
+ const { http, calls } = recording();
230
+ const { signer, signed } = stubSigner();
231
+ await avpCall("ListPolicies", { policyStoreId: "PS-abc" }, {
232
+ endpoint: "https://vpce-1234.verifiedpermissions.us-west-2.vpce.amazonaws.com",
233
+ region: "us-west-2",
234
+ credentials,
235
+ signer,
236
+ signEndpointOverride: true,
237
+ now,
238
+ http,
239
+ env: {},
240
+ });
241
+ expect(calls[0]!.headers.authorization).toMatch(/Signature=[0-9a-f]{64}$/);
242
+ expect(signed[0]!.url).toBe("https://vpce-1234.verifiedpermissions.us-west-2.vpce.amazonaws.com/");
243
+ });
244
+ });
245
+
246
+ describe("resolveAvpCredentials", () => {
247
+ test("literal credentials are final", () => {
248
+ expect(resolveAvpCredentials(credentials, { AWS_ACCESS_KEY_ID: "other" })).toEqual(credentials);
249
+ });
250
+
251
+ test("a resolver is authoritative in both directions", () => {
252
+ expect(resolveAvpCredentials(() => credentials, {})).toEqual(credentials);
253
+ expect(resolveAvpCredentials(() => undefined, { AWS_ACCESS_KEY_ID: "a", AWS_SECRET_ACCESS_KEY: "b" })).toBeUndefined();
254
+ });
255
+
256
+ test("the environment answers when nothing was passed", () => {
257
+ expect(
258
+ resolveAvpCredentials(undefined, {
259
+ AWS_ACCESS_KEY_ID: "a",
260
+ AWS_SECRET_ACCESS_KEY: "b",
261
+ AWS_SESSION_TOKEN: "t",
262
+ }),
263
+ ).toEqual({ accessKeyId: "a", secretAccessKey: "b", sessionToken: "t" });
264
+ });
265
+
266
+ test("a half-set environment is absent, not a signature that cannot verify", () => {
267
+ expect(resolveAvpCredentials(undefined, { AWS_ACCESS_KEY_ID: "a" })).toBeUndefined();
268
+ expect(resolveAvpCredentials(undefined, { AWS_SECRET_ACCESS_KEY: "b" })).toBeUndefined();
269
+ expect(resolveAvpCredentials(undefined, {})).toBeUndefined();
270
+ });
271
+ });
package/src/avp/client.ts CHANGED
@@ -14,14 +14,37 @@
14
14
  *
15
15
  * ## Signing
16
16
  *
17
- * Requests are unsigned, exactly like the aws lexicon's read client. Real AWS
18
- * rejects them; an emulator with an endpoint override does not. This is
19
- * therefore an emulator-and-test transport today, and the honest consequence is
20
- * encoded in {@link credentialsAvailable}: with no credentials and no endpoint
21
- * override, the observation reports every entity NOT-OBSERVED with
22
- * `no-credentials` rather than issuing a request that will fail. The signed
23
- * path lands when SigV4 lands in `lexicons/aws/src/api/read-client.ts`, which
24
- * is where it belongs — one implementation, not two.
17
+ * SigV4 lives once, in `lexicons/aws/src/api/sigv4.ts` (#1686). This module
18
+ * does not import it. A cedar → aws dependency edge would make the vendor-
19
+ * neutral lexicon unbuildable without the AWS one, which is the same rule that
20
+ * kept `src/avp/embed.ts` free of the aws lexicon: the seam is the data shape.
21
+ * So the signer arrives as a function on {@link AvpClientOptions}, typed here
22
+ * structurally against what the aws lexicon exports, and a project holding both
23
+ * lexicons wires them in one line:
24
+ *
25
+ * ```ts
26
+ * import { signRequest } from "@intentius/chant-lexicon-aws";
27
+ * import { describeAvpResources } from "@intentius/chant-lexicon-cedar";
28
+ *
29
+ * await describeAvpResources({
30
+ * environment,
31
+ * entityNames,
32
+ * entities,
33
+ * client: { region: "us-west-2", signer: signRequest },
34
+ * });
35
+ * ```
36
+ *
37
+ * Three cases go out unsigned, byte for byte as they did before signing
38
+ * existed — the credential scope of {@link regionScope} and nothing more:
39
+ *
40
+ * - **No signer.** The default, and the reason `credentialsAvailable` still
41
+ * gates the readers: a caller that wires nothing in is still an
42
+ * emulator-and-test transport, and real AWS still rejects it.
43
+ * - **No credentials.** A signer with nothing to sign with does not run.
44
+ * - **An endpoint override.** An emulator does not verify signatures, and
45
+ * signing against one would make every local lane need credentials to read
46
+ * what it just deployed. `signEndpointOverride` opts back in for an
47
+ * override that *is* real AWS — a VPC endpoint, a signing proxy.
25
48
  */
26
49
 
27
50
  const DEFAULT_REGION = "us-east-1";
@@ -53,6 +76,43 @@ export class AvpReadError extends Error {
53
76
  }
54
77
  }
55
78
 
79
+ /**
80
+ * A resolved credential set — the aws lexicon's `AwsCredentials`, restated so
81
+ * that this file compiles without it. `sessionToken` is present for STS/role
82
+ * credentials.
83
+ */
84
+ export interface AvpCredentials {
85
+ accessKeyId: string;
86
+ secretAccessKey: string;
87
+ sessionToken?: string;
88
+ }
89
+
90
+ /** A function that decides what to sign with; `undefined` means "nothing to". */
91
+ export type AvpCredentialResolver = () => AvpCredentials | undefined;
92
+
93
+ /** Either literal credentials or a resolver for them. */
94
+ export type AvpCredentialSource = AvpCredentials | AvpCredentialResolver;
95
+
96
+ /** One request to sign — the aws lexicon's `SigV4Request`, restated. */
97
+ export interface AvpSignableRequest {
98
+ method: string;
99
+ url: string;
100
+ headers: Record<string, string>;
101
+ body: string;
102
+ service: string;
103
+ region: string;
104
+ credentials: AvpCredentials;
105
+ /** Signing clock. Injected by tests; otherwise now. */
106
+ now?: Date;
107
+ }
108
+
109
+ /**
110
+ * The signing seam. `signRequest` from `@intentius/chant-lexicon-aws` satisfies
111
+ * this as it stands — the shapes above are its own, restated rather than
112
+ * imported, so cedar keeps no edge to the aws lexicon.
113
+ */
114
+ export type AvpSigner = (request: AvpSignableRequest) => Record<string, string>;
115
+
56
116
  export interface AvpClientOptions {
57
117
  /** Endpoint override (an emulator, or a VPC endpoint). Omit for real AWS hosts. */
58
118
  endpoint?: string;
@@ -62,6 +122,43 @@ export interface AvpClientOptions {
62
122
  signal?: AbortSignal;
63
123
  /** Environment to read credentials from. Defaults to `process.env`; injectable for tests. */
64
124
  env?: Record<string, string | undefined>;
125
+ /** SigV4, injected. Omitted, every request goes out unsigned as it always did. */
126
+ signer?: AvpSigner;
127
+ /**
128
+ * What to sign with: literal credentials, or a resolver that decides. Omitted,
129
+ * the environment answers; when it has nothing, no signature is produced.
130
+ */
131
+ credentials?: AvpCredentialSource;
132
+ /** Sign even against an endpoint override — for an override that is real AWS. */
133
+ signEndpointOverride?: boolean;
134
+ /** Signing clock. Injected by tests so a signature is reproducible. */
135
+ now?: Date;
136
+ }
137
+
138
+ /**
139
+ * Credentials for a request: explicit → environment → absent.
140
+ *
141
+ * The same rule the aws lexicon's `resolveCredentials` applies, and for the same
142
+ * reasons: an injected resolver is authoritative and its refusal is not
143
+ * second-guessed against the environment, and a half-set environment is absent
144
+ * rather than a signature that cannot verify. A caller with a profile file, IMDS
145
+ * or a container credential endpoint resolves those itself and passes the result
146
+ * in — that is what the resolver form is for.
147
+ */
148
+ export function resolveAvpCredentials(
149
+ source?: AvpCredentialSource,
150
+ env: Record<string, string | undefined> = process.env,
151
+ ): AvpCredentials | undefined {
152
+ if (typeof source === "function") return source();
153
+ if (source) return source;
154
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
155
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
156
+ if (!accessKeyId || !secretAccessKey) return undefined;
157
+ return {
158
+ accessKeyId,
159
+ secretAccessKey,
160
+ ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}),
161
+ };
65
162
  }
66
163
 
67
164
  /** Service host for AVP, honouring an endpoint override. */
@@ -95,7 +192,8 @@ export function credentialsAvailable(env: Record<string, string | undefined> = p
95
192
  * This is NOT SigV4 — the signature is a placeholder, exactly as in the aws
96
193
  * lexicon's read client, and it must not be mistaken for a signed read path. It
97
194
  * carries the region so that an endpoint override (one host for every region)
98
- * still reaches the right one.
195
+ * still reaches the right one, and it is only ever sent when no signature was
196
+ * produced; the moment one is, {@link requestHeaders} sends that instead.
99
197
  */
100
198
  function regionScope(region: string | undefined, env: Record<string, string | undefined>): Record<string, string> {
101
199
  if (!region) return {};
@@ -112,6 +210,44 @@ function isRecord(value: unknown): value is Record<string, unknown> {
112
210
  return typeof value === "object" && value !== null && !Array.isArray(value);
113
211
  }
114
212
 
213
+ /**
214
+ * The headers one request goes out with — signed when a signer was injected,
215
+ * something resolved to sign with, and the target is real AWS; scope-only in
216
+ * every other case, which is the path this transport had before signing.
217
+ *
218
+ * Signing needs a region even when the caller named none, because the scope
219
+ * string has a slot for one; it borrows the same default {@link avpUrl} used to
220
+ * build the host, so the signature agrees with the endpoint it is sent to.
221
+ */
222
+ function requestHeaders(
223
+ operation: string,
224
+ url: string,
225
+ body: string,
226
+ endpoint: string | undefined,
227
+ options: AvpClientOptions,
228
+ env: Record<string, string | undefined>,
229
+ ): Record<string, string> {
230
+ const base: Record<string, string> = {
231
+ "content-type": "application/x-amz-json-1.0",
232
+ "x-amz-target": `${TARGET_PREFIX}.${operation}`,
233
+ };
234
+ const signer = options.signer;
235
+ const credentials = signer ? resolveAvpCredentials(options.credentials, env) : undefined;
236
+ if (!signer || !credentials || (endpoint && options.signEndpointOverride !== true)) {
237
+ return { ...base, ...regionScope(options.region, env) };
238
+ }
239
+ return signer({
240
+ method: "POST",
241
+ url,
242
+ headers: base,
243
+ body,
244
+ service: SERVICE,
245
+ region: options.region ?? DEFAULT_REGION,
246
+ credentials,
247
+ ...(options.now ? { now: options.now } : {}),
248
+ });
249
+ }
250
+
115
251
  /** One AVP call. Throws {@link AvpReadError} carrying the service's `__type`. */
116
252
  export async function avpCall(
117
253
  operation: string,
@@ -120,16 +256,14 @@ export async function avpCall(
120
256
  ): Promise<Record<string, unknown>> {
121
257
  const env = options.env ?? process.env;
122
258
  const http = options.http ?? defaultHttp;
123
- const url = avpUrl(options.endpoint ?? env.AWS_ENDPOINT_URL, options.region);
259
+ const endpoint = options.endpoint ?? env.AWS_ENDPOINT_URL;
260
+ const url = avpUrl(endpoint, options.region);
261
+ const payloadJson = JSON.stringify(payload);
124
262
  const res = await http(
125
263
  url,
126
264
  {
127
- headers: {
128
- "content-type": "application/x-amz-json-1.0",
129
- "x-amz-target": `${TARGET_PREFIX}.${operation}`,
130
- ...regionScope(options.region, env),
131
- },
132
- body: JSON.stringify(payload),
265
+ headers: requestHeaders(operation, url, payloadJson, endpoint, options, env),
266
+ body: payloadJson,
133
267
  },
134
268
  options.signal,
135
269
  );