diff --git a/src/core/identity.tsx b/src/core/identity.tsx index 6ef9e3b1e..0c50f7602 100644 --- a/src/core/identity.tsx +++ b/src/core/identity.tsx @@ -10,7 +10,11 @@ import { type ListApiKeyCredentialProvidersResponse, type UpdateApiKeyCredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { CoreIdentityClient } from "../handlers/identity/types"; +import type { + CoreIdentityClient, + CreateApiKeyCredentialProviderInput, + UpdateApiKeyCredentialProviderInput, +} from "../handlers/identity/types"; import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; @@ -18,13 +22,12 @@ export class IdentityClient implements CoreIdentityClient { constructor(private readonly clients: AwsClients) {} async createApiKeyCredentialProvider( - name: string, - apiKey: string, + input: CreateApiKeyCredentialProviderInput, options: CoreOptions, ): Promise { return this.clients .control(toClientConfig(options)) - .send(new CreateApiKeyCredentialProviderCommand({ name, apiKey })); + .send(new CreateApiKeyCredentialProviderCommand(input)); } async getApiKeyCredentialProvider( @@ -47,13 +50,12 @@ export class IdentityClient implements CoreIdentityClient { } async updateApiKeyCredentialProvider( - name: string, - apiKey: string, + input: UpdateApiKeyCredentialProviderInput, options: CoreOptions, ): Promise { return this.clients .control(toClientConfig(options)) - .send(new UpdateApiKeyCredentialProviderCommand({ name, apiKey })); + .send(new UpdateApiKeyCredentialProviderCommand(input)); } async deleteApiKeyCredentialProvider( diff --git a/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.83b094791cea0052.json b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.83b094791cea0052.json new file mode 100644 index 000000000..47982402a --- /dev/null +++ b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.83b094791cea0052.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-21072164-GDCjeF" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.dfed014f440c1884.json b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.dfed014f440c1884.json new file mode 100644 index 000000000..634f2c708 --- /dev/null +++ b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.dfed014f440c1884.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-2-2a7aabf1-MsSy03" + }, + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json b/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json index e24f8571a..0317d054a 100644 --- a/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json +++ b/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json @@ -1,14 +1,14 @@ { "apiKeySecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-34b3f8a9-cHh41n" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-21072164-GDCjeF" }, "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", "createdTime": { - "$date": "2026-07-23T16:31:54.009Z" + "$date": "2026-07-24T17:00:22.115Z" }, "lastUpdatedTime": { - "$date": "2026-07-23T16:31:54.009Z" + "$date": "2026-07-24T17:00:22.115Z" }, "apiKeySecretSource": "MANAGED" } \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json index 302dbefb1..baf210512 100644 --- a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json +++ b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json @@ -4,20 +4,20 @@ "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", "createdTime": { - "$date": "2026-07-23T16:31:54.009Z" + "$date": "2026-07-24T17:00:22.115Z" }, "lastUpdatedTime": { - "$date": "2026-07-23T16:31:54.009Z" + "$date": "2026-07-24T17:00:22.115Z" } }, { "name": "agentcore-cli-identity-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", "createdTime": { - "$date": "2026-07-23T16:31:54.309Z" + "$date": "2026-07-24T17:00:22.404Z" }, "lastUpdatedTime": { - "$date": "2026-07-23T16:31:54.309Z" + "$date": "2026-07-24T17:00:22.404Z" } } ] diff --git a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.6e1f31dcdf053d87.json b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.6e1f31dcdf053d87.json index 4c7d1281d..551e34e22 100644 --- a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.6e1f31dcdf053d87.json +++ b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.6e1f31dcdf053d87.json @@ -4,10 +4,10 @@ "name": "agentcore-cli-identity-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", "createdTime": { - "$date": "2026-07-23T16:31:54.309Z" + "$date": "2026-07-24T17:00:22.404Z" }, "lastUpdatedTime": { - "$date": "2026-07-23T16:31:54.309Z" + "$date": "2026-07-24T17:00:22.404Z" } } ] diff --git a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json index e0cc1edb9..e22a6d263 100644 --- a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json +++ b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json @@ -4,10 +4,10 @@ "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", "createdTime": { - "$date": "2026-07-23T16:31:54.009Z" + "$date": "2026-07-24T17:00:22.115Z" }, "lastUpdatedTime": { - "$date": "2026-07-23T16:31:54.009Z" + "$date": "2026-07-24T17:00:22.115Z" } } ], diff --git a/src/handlers/identity/__fixtures__/UpdateApiKeyCredentialProviderCommand.a779aef2084d562f.json b/src/handlers/identity/__fixtures__/UpdateApiKeyCredentialProviderCommand.a779aef2084d562f.json new file mode 100644 index 000000000..0317d054a --- /dev/null +++ b/src/handlers/identity/__fixtures__/UpdateApiKeyCredentialProviderCommand.a779aef2084d562f.json @@ -0,0 +1,14 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-21072164-GDCjeF" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": { + "$date": "2026-07-24T17:00:22.115Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-24T17:00:22.115Z" + }, + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/create-2.golden.json b/src/handlers/identity/__fixtures__/create-2.golden.json index 429b6ec9c..634f2c708 100644 --- a/src/handlers/identity/__fixtures__/create-2.golden.json +++ b/src/handlers/identity/__fixtures__/create-2.golden.json @@ -1,6 +1,6 @@ { "apiKeySecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-2-29a3e5b6-qiRJxb" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-2-2a7aabf1-MsSy03" }, "name": "agentcore-cli-identity-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", diff --git a/src/handlers/identity/__fixtures__/create.golden.json b/src/handlers/identity/__fixtures__/create.golden.json index 23d34eaa3..47982402a 100644 --- a/src/handlers/identity/__fixtures__/create.golden.json +++ b/src/handlers/identity/__fixtures__/create.golden.json @@ -1,6 +1,6 @@ { "apiKeySecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-34b3f8a9-cHh41n" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-21072164-GDCjeF" }, "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", diff --git a/src/handlers/identity/__fixtures__/get.golden.json b/src/handlers/identity/__fixtures__/get.golden.json index 9360f4609..d236d9e3f 100644 --- a/src/handlers/identity/__fixtures__/get.golden.json +++ b/src/handlers/identity/__fixtures__/get.golden.json @@ -1,10 +1,10 @@ { "apiKeySecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-34b3f8a9-cHh41n" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-21072164-GDCjeF" }, "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", - "createdTime": "2026-07-23T16:31:54.009Z", - "lastUpdatedTime": "2026-07-23T16:31:54.009Z", + "createdTime": "2026-07-24T17:00:22.115Z", + "lastUpdatedTime": "2026-07-24T17:00:22.115Z", "apiKeySecretSource": "MANAGED" } \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/list-page-1.golden.json b/src/handlers/identity/__fixtures__/list-page-1.golden.json index 455b164f3..8b95a7631 100644 --- a/src/handlers/identity/__fixtures__/list-page-1.golden.json +++ b/src/handlers/identity/__fixtures__/list-page-1.golden.json @@ -3,8 +3,8 @@ { "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", - "createdTime": "2026-07-23T16:31:54.009Z", - "lastUpdatedTime": "2026-07-23T16:31:54.009Z" + "createdTime": "2026-07-24T17:00:22.115Z", + "lastUpdatedTime": "2026-07-24T17:00:22.115Z" } ], "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjg1MTk3NzA4Njg3I0FQSV9LRVkiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6ImFnZW50Y29yZS1jbGktaWRlbnRpdHktZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlBLIjp7InZhbHVlU3RyIjoiQVBJX0tFWV9DUkVEX1BST1ZJREVSIzY4NTE5NzcwODY4NyNkZWZhdWx0I2FnZW50Y29yZS1jbGktaWRlbnRpdHktZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn19" diff --git a/src/handlers/identity/__fixtures__/list-page-2.golden.json b/src/handlers/identity/__fixtures__/list-page-2.golden.json index 6f0f60522..49b746a39 100644 --- a/src/handlers/identity/__fixtures__/list-page-2.golden.json +++ b/src/handlers/identity/__fixtures__/list-page-2.golden.json @@ -3,8 +3,8 @@ { "name": "agentcore-cli-identity-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", - "createdTime": "2026-07-23T16:31:54.309Z", - "lastUpdatedTime": "2026-07-23T16:31:54.309Z" + "createdTime": "2026-07-24T17:00:22.404Z", + "lastUpdatedTime": "2026-07-24T17:00:22.404Z" } ] } \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/list.golden.json b/src/handlers/identity/__fixtures__/list.golden.json index b3501a60a..6ae3d954c 100644 --- a/src/handlers/identity/__fixtures__/list.golden.json +++ b/src/handlers/identity/__fixtures__/list.golden.json @@ -3,14 +3,14 @@ { "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", - "createdTime": "2026-07-23T16:31:54.009Z", - "lastUpdatedTime": "2026-07-23T16:31:54.009Z" + "createdTime": "2026-07-24T17:00:22.115Z", + "lastUpdatedTime": "2026-07-24T17:00:22.115Z" }, { "name": "agentcore-cli-identity-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", - "createdTime": "2026-07-23T16:31:54.309Z", - "lastUpdatedTime": "2026-07-23T16:31:54.309Z" + "createdTime": "2026-07-24T17:00:22.404Z", + "lastUpdatedTime": "2026-07-24T17:00:22.404Z" } ] } \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/update.golden.json b/src/handlers/identity/__fixtures__/update.golden.json index 9360f4609..d236d9e3f 100644 --- a/src/handlers/identity/__fixtures__/update.golden.json +++ b/src/handlers/identity/__fixtures__/update.golden.json @@ -1,10 +1,10 @@ { "apiKeySecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-34b3f8a9-cHh41n" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-21072164-GDCjeF" }, "name": "agentcore-cli-identity-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", - "createdTime": "2026-07-23T16:31:54.009Z", - "lastUpdatedTime": "2026-07-23T16:31:54.009Z", + "createdTime": "2026-07-24T17:00:22.115Z", + "lastUpdatedTime": "2026-07-24T17:00:22.115Z", "apiKeySecretSource": "MANAGED" } \ No newline at end of file diff --git a/src/handlers/identity/api-key-credential-provider/create/index.tsx b/src/handlers/identity/api-key-credential-provider/create/index.tsx index 72b671a02..73389b42e 100644 --- a/src/handlers/identity/api-key-credential-provider/create/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/create/index.tsx @@ -2,32 +2,61 @@ import z from "zod"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import type { AppIO } from "../../../../io"; +import { coreOptsFromCtx, parseTags } from "../../../utils"; +import { SourceResolver } from "../../../../io"; +import { parseSecretReference } from "../parser"; -export const createCreateApiKeyCredentialProviderHandler = (core: Core) => +export const createCreateApiKeyCredentialProviderHandler = (core: Core, io: AppIO) => createHandler({ name: "create", description: "create an API key credential provider", flags: [ flag("name", "the name of the API key credential provider", z.string().optional()), - flag("api-key", "the API key value", z.string().optional()), + flag("api-key", "the API key value (inline, file://path, or -)", z.string().optional(), { + sensitive: true, + }), + flag( + "api-key-secret-reference", + 'external secret reference JSON: {"secretId":"","jsonKey":""}', + z.string().optional(), + ), + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), ], handle: async (ctx, flags) => { if (!flags.name) { throw new TypeError("required option '--name ' not specified"); } - if (!flags["api-key"]) { - throw new TypeError("required option '--api-key ' not specified"); + + const hasApiKey = flags["api-key"] !== undefined; + const hasSecretRef = flags["api-key-secret-reference"] !== undefined; + + if (hasApiKey && hasSecretRef) { + throw new TypeError("--api-key and --api-key-secret-reference are mutually exclusive"); } + if (!hasApiKey && !hasSecretRef) { + throw new TypeError("either --api-key or --api-key-secret-reference is required"); + } + + const resolver = new SourceResolver({ stdin: io.stdin }); + const apiKey = await resolver.resolveText("api-key", flags["api-key"]); + const apiKeySecretConfig = hasSecretRef + ? parseSecretReference(flags["api-key-secret-reference"]!) + : undefined; + const apiKeySecretSource = hasApiKey ? "MANAGED" : "EXTERNAL"; + const tags = parseTags(flags.tags); - ctx - .require(JsonRendererKey) - .renderJson( - await core.identity.createApiKeyCredentialProvider( - flags.name, - flags["api-key"], - coreOptsFromCtx(ctx), - ), - ); + ctx.require(JsonRendererKey).renderJson( + await core.identity.createApiKeyCredentialProvider( + { + name: flags.name, + apiKey, + apiKeySecretConfig, + apiKeySecretSource: apiKeySecretSource as any, + tags, + }, + coreOptsFromCtx(ctx), + ), + ); }, }); diff --git a/src/handlers/identity/api-key-credential-provider/index.tsx b/src/handlers/identity/api-key-credential-provider/index.tsx index ffb308599..9d975d25e 100644 --- a/src/handlers/identity/api-key-credential-provider/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/index.tsx @@ -11,9 +11,9 @@ import { createUpdateApiKeyCredentialProviderHandler } from "./update"; export function createApiKeyCredentialProviderHandler(core: Core, io: AppIO): Router { return new Router("api-key-credential-provider", "manage API key credential providers") .default(createHelpDefault(io)) - .handler(createCreateApiKeyCredentialProviderHandler(core)) + .handler(createCreateApiKeyCredentialProviderHandler(core, io)) .handler(createGetApiKeyCredentialProviderHandler(core)) .handler(createListApiKeyCredentialProvidersHandler(core)) - .handler(createUpdateApiKeyCredentialProviderHandler(core)) + .handler(createUpdateApiKeyCredentialProviderHandler(core, io)) .handler(createDeleteApiKeyCredentialProviderHandler(core)); } diff --git a/src/handlers/identity/api-key-credential-provider/parser.test.tsx b/src/handlers/identity/api-key-credential-provider/parser.test.tsx new file mode 100644 index 000000000..29d7b204b --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/parser.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { parseSecretReference } from "./parser"; + +describe("parseSecretReference", () => { + test("parses a valid secret reference", () => { + const result = parseSecretReference( + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:my-secret","jsonKey":"apiKey"}', + ); + expect(result).toEqual({ + secretId: "arn:aws:secretsmanager:us-west-2:123:secret:my-secret", + jsonKey: "apiKey", + }); + }); + + test("rejects invalid JSON", () => { + expect(() => parseSecretReference("{not json}")).toThrow("Invalid JSON"); + }); + + test("rejects non-object input", () => { + expect(() => parseSecretReference('"just a string"')).toThrow("must be a JSON object"); + }); + + test("rejects array input", () => { + expect(() => parseSecretReference("[]")).toThrow("must be a JSON object"); + }); + + test("rejects missing secretId", () => { + expect(() => parseSecretReference('{"jsonKey":"apiKey"}')).toThrow('non-empty "secretId"'); + }); + + test("rejects missing jsonKey", () => { + expect(() => + parseSecretReference('{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s"}'), + ).toThrow('non-empty "jsonKey"'); + }); + + test("rejects empty secretId", () => { + expect(() => parseSecretReference('{"secretId":"","jsonKey":"apiKey"}')).toThrow( + 'non-empty "secretId"', + ); + }); + + test("rejects empty jsonKey", () => { + expect(() => + parseSecretReference( + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":""}', + ), + ).toThrow('non-empty "jsonKey"'); + }); + + test("rejects unexpected fields", () => { + expect(() => + parseSecretReference( + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey","extra":"bad"}', + ), + ).toThrow("unexpected fields: extra"); + }); + + test("rejects non-string secretId", () => { + expect(() => parseSecretReference('{"secretId":123,"jsonKey":"apiKey"}')).toThrow( + 'non-empty "secretId"', + ); + }); + + test("rejects non-string jsonKey", () => { + expect(() => + parseSecretReference( + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":true}', + ), + ).toThrow('non-empty "jsonKey"'); + }); +}); diff --git a/src/handlers/identity/api-key-credential-provider/parser.tsx b/src/handlers/identity/api-key-credential-provider/parser.tsx new file mode 100644 index 000000000..eb38dddf2 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/parser.tsx @@ -0,0 +1,35 @@ +// Requires exactly { secretId, jsonKey } with non-empty string values. +export function parseSecretReference(raw: string): { secretId: string; jsonKey: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new TypeError( + `Invalid JSON for --api-key-secret-reference: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new TypeError( + '--api-key-secret-reference must be a JSON object with "secretId" and "jsonKey"', + ); + } + + const obj = parsed as Record; + const allowedKeys = new Set(["secretId", "jsonKey"]); + const unexpected = Object.keys(obj).filter((k) => !allowedKeys.has(k)); + if (unexpected.length > 0) { + throw new TypeError( + `--api-key-secret-reference contains unexpected fields: ${unexpected.join(", ")}`, + ); + } + + if (typeof obj.secretId !== "string" || obj.secretId.length === 0) { + throw new TypeError('--api-key-secret-reference requires a non-empty "secretId" string'); + } + if (typeof obj.jsonKey !== "string" || obj.jsonKey.length === 0) { + throw new TypeError('--api-key-secret-reference requires a non-empty "jsonKey" string'); + } + + return { secretId: obj.secretId, jsonKey: obj.jsonKey }; +} diff --git a/src/handlers/identity/api-key-credential-provider/update/index.tsx b/src/handlers/identity/api-key-credential-provider/update/index.tsx index b081d5198..6c289f1cb 100644 --- a/src/handlers/identity/api-key-credential-provider/update/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/update/index.tsx @@ -2,32 +2,70 @@ import z from "zod"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; +import type { AppIO } from "../../../../io"; import { coreOptsFromCtx } from "../../../utils"; +import { SourceResolver } from "../../../../io"; +import { parseSecretReference } from "../parser"; -export const createUpdateApiKeyCredentialProviderHandler = (core: Core) => +export const createUpdateApiKeyCredentialProviderHandler = (core: Core, io: AppIO) => createHandler({ name: "update", description: "update an API key credential provider", flags: [ flag("name", "the name of the API key credential provider", z.string().optional()), - flag("api-key", "the new API key value", z.string().optional()), + flag("api-key", "the new API key value (inline, file://path, or -)", z.string().optional(), { + sensitive: true, + }), + flag( + "api-key-secret-reference", + 'external secret reference JSON: {"secretId":"","jsonKey":""}', + z.string().optional(), + ), ], handle: async (ctx, flags) => { if (!flags.name) { throw new TypeError("required option '--name ' not specified"); } - if (!flags["api-key"]) { - throw new TypeError("required option '--api-key ' not specified"); + + const hasApiKey = flags["api-key"] !== undefined; + const hasSecretRef = flags["api-key-secret-reference"] !== undefined; + + if (hasApiKey && hasSecretRef) { + throw new TypeError("--api-key and --api-key-secret-reference are mutually exclusive"); } + if (!hasApiKey && !hasSecretRef) { + throw new TypeError("either --api-key or --api-key-secret-reference is required"); + } + + const opts = coreOptsFromCtx(ctx); + const existing = await core.identity.getApiKeyCredentialProvider(flags.name, opts); + const existingSource = existing.apiKeySecretSource; - ctx - .require(JsonRendererKey) - .renderJson( - await core.identity.updateApiKeyCredentialProvider( - flags.name, - flags["api-key"], - coreOptsFromCtx(ctx), - ), + if (hasApiKey && existingSource === "EXTERNAL") { + throw new TypeError( + "this provider uses an external secret; use --api-key-secret-reference to update it", ); + } + if (hasSecretRef && existingSource === "MANAGED") { + throw new TypeError("this provider uses a managed secret; use --api-key to update it"); + } + + const resolver = new SourceResolver({ stdin: io.stdin }); + const apiKey = await resolver.resolveText("api-key", flags["api-key"]); + const apiKeySecretConfig = hasSecretRef + ? parseSecretReference(flags["api-key-secret-reference"]!) + : undefined; + + ctx.require(JsonRendererKey).renderJson( + await core.identity.updateApiKeyCredentialProvider( + { + name: flags.name, + apiKey, + apiKeySecretConfig, + apiKeySecretSource: existingSource, + }, + opts, + ), + ); }, }); diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index 02f75a680..7c04997af 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -193,7 +193,7 @@ describe("api-key-credential-provider CRUDL", () => { [ "create --name only", ["identity", "api-key-credential-provider", "create", "--name", "x"], - /--api-key/, + /--api-key.*--api-key-secret-reference/, ], [ "create --api-key only", @@ -206,13 +206,74 @@ describe("api-key-credential-provider CRUDL", () => { [ "update --name only", ["identity", "api-key-credential-provider", "update", "--name", "x"], - /--api-key/, + /--api-key.*--api-key-secret-reference/, ], ["delete bare", ["identity", "api-key-credential-provider", "delete"], /--name/], ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); + test.each([ + [ + "create: --api-key with --api-key-secret-reference", + [ + "identity", + "api-key-credential-provider", + "create", + "--name", + "x", + "--api-key", + "k", + "--api-key-secret-reference", + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey"}', + ], + /mutually exclusive/, + ], + [ + "create: --api-key-secret-reference missing secretId", + [ + "identity", + "api-key-credential-provider", + "create", + "--name", + "x", + "--api-key-secret-reference", + '{"jsonKey":"apiKey"}', + ], + /non-empty "secretId"/, + ], + [ + "create: --api-key-secret-reference with unexpected field", + [ + "identity", + "api-key-credential-provider", + "create", + "--name", + "x", + "--api-key-secret-reference", + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey","extra":"bad"}', + ], + /unexpected fields/, + ], + [ + "update: --api-key with --api-key-secret-reference", + [ + "identity", + "api-key-credential-provider", + "update", + "--name", + "x", + "--api-key", + "k", + "--api-key-secret-reference", + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey"}', + ], + /mutually exclusive/, + ], + ] as const)("rejects invalid secret input for `%s`", async (_label, args, message) => { + expect(run([...args])).rejects.toThrow(message); + }); + test("propagates ResourceNotFoundException from get", async () => { await expect( run(["identity", "api-key-credential-provider", "get", "--name", MISSING_PROVIDER_NAME]), diff --git a/src/handlers/identity/types.tsx b/src/handlers/identity/types.tsx index 9a6d36cc0..7c11c75a1 100644 --- a/src/handlers/identity/types.tsx +++ b/src/handlers/identity/types.tsx @@ -1,16 +1,20 @@ import type { + CreateApiKeyCredentialProviderRequest, CreateApiKeyCredentialProviderResponse, DeleteApiKeyCredentialProviderResponse, GetApiKeyCredentialProviderResponse, ListApiKeyCredentialProvidersResponse, + UpdateApiKeyCredentialProviderRequest, UpdateApiKeyCredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; +export type CreateApiKeyCredentialProviderInput = CreateApiKeyCredentialProviderRequest; +export type UpdateApiKeyCredentialProviderInput = UpdateApiKeyCredentialProviderRequest; + export interface CoreIdentityClient { createApiKeyCredentialProvider( - name: string, - apiKey: string, + input: CreateApiKeyCredentialProviderInput, options: CoreOptions, ): Promise; getApiKeyCredentialProvider( @@ -23,8 +27,7 @@ export interface CoreIdentityClient { options: CoreOptions, ): Promise; updateApiKeyCredentialProvider( - name: string, - apiKey: string, + input: UpdateApiKeyCredentialProviderInput, options: CoreOptions, ): Promise; deleteApiKeyCredentialProvider( diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx new file mode 100644 index 000000000..b9d5a384a --- /dev/null +++ b/src/handlers/utils.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { parseTags } from "./utils"; + +describe("parseTags", () => { + test("returns undefined for undefined input", () => { + expect(parseTags(undefined)).toBeUndefined(); + }); + + test("returns undefined for empty array", () => { + expect(parseTags([])).toBeUndefined(); + }); + + test("parses a single key=value pair", () => { + expect(parseTags(["env=prod"])).toEqual({ env: "prod" }); + }); + + test("parses multiple key=value pairs", () => { + expect(parseTags(["env=prod", "team=agentcore"])).toEqual({ + env: "prod", + team: "agentcore", + }); + }); + + test("handles values containing equals signs", () => { + expect(parseTags(["config=a=b=c"])).toEqual({ config: "a=b=c" }); + }); + + test("parses a JSON object", () => { + expect(parseTags(['{"env":"prod","team":"agentcore"}'])).toEqual({ + env: "prod", + team: "agentcore", + }); + }); + + test("rejects invalid JSON", () => { + expect(() => parseTags(["{not json}"])).toThrow("Invalid JSON"); + }); + + test("rejects JSON array (not an object)", () => { + expect(() => parseTags(['["a","b"]'])).toThrow("expected key=value"); + }); + + test("rejects JSON with non-string values", () => { + expect(() => parseTags(['{"count":42}'])).toThrow("must be a string, got number"); + }); + + test("rejects key=value without a key", () => { + expect(() => parseTags(["=value"])).toThrow("expected key=value"); + }); + + test("rejects bare value without equals", () => { + expect(() => parseTags(["noequals"])).toThrow("expected key=value"); + }); +}); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 89a79d2e7..245af9e63 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -28,3 +28,38 @@ export function parseJsonFlag(name: string, raw: string | undefined): T | und ); } } + +// parseTags parses a tags flag that accepts two mutually exclusive forms: +// - Repeated key=value shorthand: ["env=prod", "team=foo"] +// - A single JSON object: ['{"env":"prod","team":"foo"}'] +// The two forms cannot be mixed. Returns undefined when the input is empty. +export function parseTags(values: string[] | undefined): Record | undefined { + if (!values || values.length === 0) return undefined; + + const first = values[0]; + if (values.length === 1 && first?.trimStart().startsWith("{")) { + const parsed = parseJsonFlag>("tags", first); + if (parsed === undefined) return undefined; + if (Array.isArray(parsed)) { + throw new TypeError("--tags JSON must be an object of string key-value pairs"); + } + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") { + throw new TypeError(`--tags value for key '${key}' must be a string, got ${typeof value}`); + } + } + return parsed as Record; + } + + const result: Record = {}; + for (const entry of values) { + const eqIndex = entry.indexOf("="); + if (eqIndex < 1) { + throw new TypeError( + `Invalid tag '${entry}': expected key=value format or a single JSON object`, + ); + } + result[entry.slice(0, eqIndex)] = entry.slice(eqIndex + 1); + } + return result; +} diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 531c2df07..78c5d5898 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -36,7 +36,11 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { Core } from "../handlers/types"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; -import type { CoreIdentityClient } from "../handlers/identity/types"; +import type { + CoreIdentityClient, + CreateApiKeyCredentialProviderInput, + UpdateApiKeyCredentialProviderInput, +} from "../handlers/identity/types"; import type { CoreRuntimeClient } from "../handlers/runtime/types"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; @@ -568,8 +572,7 @@ type TestCoreClientOptions = { class TestIdentityClient implements CoreIdentityClient { async createApiKeyCredentialProvider( - _name: string, - _apiKey: string, + _input: CreateApiKeyCredentialProviderInput, _options: CoreOptions, ): Promise { return DEFAULT_CREATE_API_KEY_RESPONSE; @@ -591,8 +594,7 @@ class TestIdentityClient implements CoreIdentityClient { } async updateApiKeyCredentialProvider( - _name: string, - _apiKey: string, + _input: UpdateApiKeyCredentialProviderInput, _options: CoreOptions, ): Promise { return DEFAULT_UPDATE_API_KEY_RESPONSE;