diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 8c0506628..d12bf39d8 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +6,7 @@ import type { DeployResult, Project, ProjectEvent } from "../../../handlers/proj import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { createSilentLogger } from "../../../testing"; import { CdkBackend } from "./cdk"; +import type { CredentialProvisioner } from "./cdk/credentials"; import { DEPLOYED_STATE_RELATIVE_PATH } from "./cdk/deployedState"; import type { DeployBackendInput } from "./types"; import type { BootstrapState } from "./cdk/environment"; @@ -120,6 +120,7 @@ type HarnessOptions = { template?: boolean; failOperation?: CdkOperation["kind"]; bootstrapError?: Error; + provisionCredentials?: CredentialProvisioner; /** Whether CloudFormation still holds the target's stack. Defaults to present. */ stackExists?: boolean; }; @@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) { }, }; }, + ...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }), }); return { @@ -312,6 +314,7 @@ describe("CdkBackend.deploy", () => { expect(JSON.parse(await Bun.file(statePath).text())).toEqual({ targets: { default: { + resources: { credentials: {} }, stackArn: "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc", }, @@ -319,7 +322,45 @@ describe("CdkBackend.deploy", () => { }); }); - test("fails a deploy whose result carries no stack ARN, recording nothing", async () => { + test("provisions credentials before synth and records them under the target", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const provisionCredentials: CredentialProvisioner = async function* () { + yield { message: "Preparing credential provider 'openai-key'" }; + return { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } }; + }; + const subject = harness({ + outputs: { RuntimeArn: "arn:runtime" }, + stackArn: "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc", + provisionCredentials, + }); + + const deployed = await collectDeploy(subject.backend.deploy(input, deployInput())); + + // The credential step runs (and its ARNs are recorded) before synthesis, so + // the assembly is synthesized against a state file that already describes them. + const messages = deployed.events.map((event) => event.message); + expect(messages.indexOf("Preparing credential provider 'openai-key'")).toBeLessThan( + messages.indexOf("Synthesizing CloudFormation templates"), + ); + + // The pre-synth credentials write and the post-deploy stack-ARN write merge + // into one target entry rather than clobbering each other. + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + expect(JSON.parse(await Bun.file(statePath).text())).toEqual({ + targets: { + default: { + stackArn: + "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc", + resources: { + credentials: { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } }, + }, + }, + }, + }); + }); + + test("fails a deploy whose result carries no stack ARN, recording no binding", async () => { const input = await project(); await writeAssembly(input, [TARGET.name]); const subject = harness({ outputs: { RuntimeArn: "arn:runtime" }, omitStackArn: true }); @@ -327,7 +368,28 @@ describe("CdkBackend.deploy", () => { await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow( /without a stack ARN/, ); - expect(existsSync(join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH))).toBe(false); + // The pre-synth credentials write may have created the file, but the failed + // deploy must not have recorded a stack binding. + const state = JSON.parse( + await Bun.file(join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH)).text(), + ); + expect(state.targets.default?.stackArn).toBeUndefined(); + }); + + test("checks local CDK prerequisites before provisioning credentials", async () => { + const input = await project(false); // no agentcore/cdk/node_modules + let provisioned = false; + // eslint-disable-next-line require-yield -- a spy that should never run (deploy fails first) + const provisionCredentials: CredentialProvisioner = async function* () { + provisioned = true; + return {}; + }; + const subject = harness({ provisionCredentials }); + + await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow( + /npm install/, + ); + expect(provisioned).toBe(false); }); test("fails before touching AWS when the existing state file is malformed", async () => { diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index c8e7fa525..79f50a440 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -13,6 +13,7 @@ import type { Logger } from "../../../logging"; import type { DeployBackendInput, ProjectBackend } from "./types"; import { createCloudFormationClient } from "../../factories"; import type { CreateCloudFormationClient } from "../../types"; +import { createCredentialProvisioner, type CredentialProvisioner } from "./cdk/credentials"; import { countDeployableResources, stackArtifactForTarget, @@ -51,6 +52,7 @@ export type CdkBackendConfig = { stack?: StackProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; + provisionCredentials?: CredentialProvisioner; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -65,6 +67,7 @@ export class CdkBackend implements ProjectBackend { private readonly stack: StackProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; + private readonly provisionCredentials: CredentialProvisioner; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -86,11 +89,13 @@ export class CdkBackend implements ProjectBackend { ((stackName, region, credentials) => probeStack(stackName, region, credentials, readStack)); this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; + this.provisionCredentials = config.provisionCredentials ?? createCredentialProvisioner(); } - public async *build(project: Project): AsyncGenerator { + // Local prerequisites for synth. Checked before any AWS mutation so a missing + // toolchain or dependencies fails without having provisioned credentials. + private async ensureCdkDependencies(project: Project): Promise { const cdkDir = this.cdkDirectory(project); - if (!existsSync(join(cdkDir, "node_modules"))) { throw new ProjectStateError( `CDK dependencies are missing for project '${project.name}'. ` + @@ -98,12 +103,16 @@ export class CdkBackend implements ProjectBackend { ); } await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); + } + + public async *build(project: Project): AsyncGenerator { + await this.ensureCdkDependencies(project); yield { message: "Synthesizing CloudFormation templates" }; await this.runner( ["npm", "run", "cdk", "--", "synth", "--quiet", "--output", this.assemblyDirectory(project)], { - cwd: cdkDir, + cwd: this.cdkDirectory(project), onOutput: (chunk) => this.logger.debug(chunk), }, ); @@ -124,11 +133,24 @@ export class CdkBackend implements ProjectBackend { ); } - // Validate any existing deployed state before mutating AWS. A malformed file - // must fail here — not after bootstrap/deploy — so we never leave AWS changed - // with the new stack ARN unrecorded because the post-deploy write can't parse it. + // Fail on local setup errors (missing toolchain/deps) and malformed state + // before any AWS mutation, so a local problem never leaves credentials + // provisioned or the stack ARN unrecorded. + await this.ensureCdkDependencies(project); await readDeployedState(this.json, project.rootPath); + // Credential providers aren't stack resources; the synthesized app reads their + // ARNs from deployed-state.json, so they must exist and be recorded before synth. + const provisioned = yield* this.provisionCredentials(project, { + credentials, + region: target.region, + }); + // Recorded every deploy (even when empty) so dropping the last credential + // from the spec clears the stale entry instead of leaving it advertised. + await updateTargetState(this.json, project.rootPath, target.name, { + resources: { credentials: provisioned }, + }); + yield* this.build(project); const assemblyDirectory = this.assemblyDirectory(project); const artifact = await stackArtifactForTarget(this.json, assemblyDirectory, target.name); diff --git a/src/core/project/backends/cdk/credentials.client.test.ts b/src/core/project/backends/cdk/credentials.client.test.ts new file mode 100644 index 000000000..8929d00da --- /dev/null +++ b/src/core/project/backends/cdk/credentials.client.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +// credentials.test.ts drives the provisioner with a fake client; this covers the +// real factory by mocking the AWS SDK it lazily imports. + +class ResourceNotFoundException extends Error { + constructor() { + super("not found"); + this.name = "ResourceNotFoundException"; + } +} +class GetApiKeyCredentialProviderCommand { + constructor(readonly input: unknown) {} +} +class CreateApiKeyCredentialProviderCommand { + constructor(readonly input: unknown) {} +} +class GetOauth2CredentialProviderCommand { + constructor(readonly input: unknown) {} +} +class CreateOauth2CredentialProviderCommand { + constructor(readonly input: unknown) {} +} + +const sent: unknown[] = []; +let send: (command: unknown) => Promise; + +class BedrockAgentCoreControlClient { + constructor(readonly config: unknown) {} + send(command: unknown) { + sent.push(command); + return send(command); + } +} + +mock.module("@aws-sdk/client-bedrock-agentcore-control", () => ({ + BedrockAgentCoreControlClient, + GetApiKeyCredentialProviderCommand, + CreateApiKeyCredentialProviderCommand, + GetOauth2CredentialProviderCommand, + CreateOauth2CredentialProviderCommand, + ResourceNotFoundException, +})); + +const { createIdentityProviderClient } = await import("./credentials"); +const credentials = async () => ({ accessKeyId: "a", secretAccessKey: "b" }); + +afterEach(() => { + sent.length = 0; +}); + +describe("createIdentityProviderClient", () => { + test("passes region and credentials to the SDK client", async () => { + send = async () => ({ credentialProviderArn: "arn:cp" }); + const client = await createIdentityProviderClient("eu-west-1", credentials); + await client.getApiKeyProvider("k"); + + expect((sent[0] as GetApiKeyCredentialProviderCommand).input).toEqual({ name: "k" }); + }); + + test("maps an API key provider, including its secret ARN", async () => { + send = async () => ({ + credentialProviderArn: "arn:cp", + apiKeySecretArn: { secretArn: "arn:secret" }, + }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + expect(await client.getApiKeyProvider("k")).toEqual({ + credentialProviderArn: "arn:cp", + clientSecretArn: "arn:secret", + }); + }); + + test("maps an OAuth2 provider it finds, including its secret ARN", async () => { + send = async () => ({ + credentialProviderArn: "arn:cp", + clientSecretArn: { secretArn: "arn:secret" }, + }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + expect(await client.getOauth2Provider("o")).toEqual({ + credentialProviderArn: "arn:cp", + clientSecretArn: "arn:secret", + }); + }); + + test("omits the secret ARN when Identity returns none", async () => { + send = async () => ({ credentialProviderArn: "arn:cp" }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + expect(await client.getApiKeyProvider("k")).toEqual({ credentialProviderArn: "arn:cp" }); + }); + + test("returns undefined when the provider does not exist", async () => { + send = async () => { + throw new ResourceNotFoundException(); + }; + const client = await createIdentityProviderClient("us-east-1", credentials); + + expect(await client.getApiKeyProvider("missing")).toBeUndefined(); + expect(await client.getOauth2Provider("missing")).toBeUndefined(); + }); + + test("propagates errors other than not-found", async () => { + const failure = Object.assign(new Error("denied"), { name: "AccessDeniedException" }); + send = async () => { + throw failure; + }; + const client = await createIdentityProviderClient("us-east-1", credentials); + + await expect(client.getApiKeyProvider("k")).rejects.toBe(failure); + }); + + test("throws when Identity returns no provider ARN", async () => { + send = async () => ({}); + const client = await createIdentityProviderClient("us-east-1", credentials); + + await expect(client.createApiKeyProvider({ name: "k", apiKey: "sk" })).rejects.toThrow( + /no credentialProviderArn/, + ); + }); + + test("creates an API key provider from an inline key", async () => { + send = async () => ({ credentialProviderArn: "arn:cp" }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + await client.createApiKeyProvider({ name: "k", apiKey: "sk-live" }); + + expect((sent[0] as CreateApiKeyCredentialProviderCommand).input).toEqual({ + name: "k", + apiKey: "sk-live", + }); + }); + + test("creates an API key provider from an external secret reference", async () => { + send = async () => ({ credentialProviderArn: "arn:cp" }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + const secretRef = { secretId: "s", jsonKey: "apiKey" }; + await client.createApiKeyProvider({ name: "k", secretRef }); + + expect((sent[0] as CreateApiKeyCredentialProviderCommand).input).toEqual({ + name: "k", + apiKeySecretConfig: secretRef, + apiKeySecretSource: "EXTERNAL", + }); + }); + + test("returns the created API key provider's secret ARN", async () => { + send = async () => ({ + credentialProviderArn: "arn:cp", + apiKeySecretArn: { secretArn: "arn:secret" }, + }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + expect(await client.createApiKeyProvider({ name: "k", apiKey: "sk" })).toEqual({ + credentialProviderArn: "arn:cp", + clientSecretArn: "arn:secret", + }); + }); + + test("creates an OAuth2 provider without a returned secret ARN", async () => { + send = async () => ({ credentialProviderArn: "arn:cp" }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + expect( + await client.createOauth2Provider({ + name: "o", + vendor: "CustomOauth2", + config: { customOauth2ProviderConfig: { oauthDiscovery: { discoveryUrl: "u" } } }, + }), + ).toEqual({ credentialProviderArn: "arn:cp" }); + }); + + test("creates an OAuth2 provider with its vendor and config", async () => { + send = async () => ({ + credentialProviderArn: "arn:cp", + clientSecretArn: { secretArn: "arn:secret" }, + }); + const client = await createIdentityProviderClient("us-east-1", credentials); + + const config = { customOauth2ProviderConfig: { oauthDiscovery: { discoveryUrl: "u" } } }; + const result = await client.createOauth2Provider({ name: "o", vendor: "CustomOauth2", config }); + + expect((sent[0] as CreateOauth2CredentialProviderCommand).input).toEqual({ + name: "o", + credentialProviderVendor: "CustomOauth2", + oauth2ProviderConfigInput: config, + }); + expect(result).toEqual({ credentialProviderArn: "arn:cp", clientSecretArn: "arn:secret" }); + }); +}); diff --git a/src/core/project/backends/cdk/credentials.test.ts b/src/core/project/backends/cdk/credentials.test.ts new file mode 100644 index 000000000..e6b4c938a --- /dev/null +++ b/src/core/project/backends/cdk/credentials.test.ts @@ -0,0 +1,340 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import type { Project, ProjectEvent } from "../../../../handlers/project/types"; +import { ProjectSpecSchema } from "../../../../projectSchemas/project"; +import { EnvLocalFile } from "../../envLocal"; +import { + createCredentialProvisioner, + type CredentialProvisioner, + type DeployedCredential, + type DeployedCredentials, + type IdentityProviderClient, +} from "./credentials"; +import type { CdkCredentialProvider } from "./toolkit"; + +const REGION = "us-east-1"; +const CREDENTIALS: CdkCredentialProvider = async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", +}); + +const API_KEY = { authorizerType: "ApiKeyCredentialProvider", name: "openai-key" } as const; +const DISCOVERY = "https://example.com/.well-known/openid-configuration"; +const OAUTH = { + authorizerType: "OAuthCredentialProvider", + name: "my-oauth", + clientId: "client-1", + discoveryUrl: DISCOVERY, + scopes: ["read"], +} as const; + +const tempDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function project(credentials: unknown[], envLocal?: string): Promise { + const rootPath = await mkdtemp(join(tmpdir(), "agentcore-credentials-")); + tempDirectories.push(rootPath); + const file = new EnvLocalFile(rootPath); + await mkdir(dirname(file.path), { recursive: true }); + if (envLocal !== undefined) await writeFile(file.path, envLocal); + return { + name: "example", + rootPath, + spec: ProjectSpecSchema.parse({ name: "example", version: 1, credentials }), + }; +} + +type Call = { kind: string; input: unknown }; + +function identity(existing: DeployedCredentials = {}) { + const calls: Call[] = []; + const factoryArgs: { region: string; credentials: CdkCredentialProvider }[] = []; + + const created = (name: string, prefix: string): DeployedCredential => ({ + credentialProviderArn: `arn:${prefix}:${name}`, + clientSecretArn: `arn:secret:${name}`, + }); + + const client: IdentityProviderClient = { + async getApiKeyProvider(name) { + calls.push({ kind: "getApiKey", input: name }); + return existing[name]; + }, + async createApiKeyProvider(input) { + calls.push({ kind: "createApiKey", input }); + return created(input.name, "apikey"); + }, + async getOauth2Provider(name) { + calls.push({ kind: "getOauth2", input: name }); + return existing[name]; + }, + async createOauth2Provider(input) { + calls.push({ kind: "createOauth2", input }); + return created(input.name, "oauth"); + }, + }; + + return { + calls, + factoryArgs, + provision: createCredentialProvisioner(async (region, credentials) => { + factoryArgs.push({ region, credentials }); + return client; + }), + }; +} + +async function run( + provision: CredentialProvisioner, + input: Project, +): Promise<{ events: ProjectEvent[]; result: DeployedCredentials }> { + const generator = provision(input, { credentials: CREDENTIALS, region: REGION }); + const events: ProjectEvent[] = []; + while (true) { + const next = await generator.next(); + if (next.done) return { events, result: next.value }; + events.push(next.value as ProjectEvent); + } +} + +describe("createCredentialProvisioner", () => { + test("does not build a client for a project without credentials", async () => { + const subject = identity(); + + const { events, result } = await run(subject.provision, await project([])); + + expect(result).toEqual({}); + expect(events).toEqual([]); + expect(subject.factoryArgs).toEqual([]); + }); + + test("builds the client against the target's own region and credentials", async () => { + const subject = identity(); + const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n"); + + await run(subject.provision, input); + + expect(subject.factoryArgs).toEqual([{ region: REGION, credentials: CREDENTIALS }]); + }); + + test("creates an API key provider from the secret in .env.local", async () => { + const subject = identity(); + const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n"); + + const { events, result } = await run(subject.provision, input); + + expect(events).toEqual([{ message: "Preparing credential provider 'openai-key'" }]); + expect(subject.calls).toEqual([ + { kind: "getApiKey", input: "openai-key" }, + { kind: "createApiKey", input: { name: "openai-key", apiKey: "sk-live" } }, + ]); + expect(result).toEqual({ + "openai-key": { + credentialProviderArn: "arn:apikey:openai-key", + clientSecretArn: "arn:secret:openai-key", + }, + }); + }); + + test("creates an API key provider from a Secrets Manager reference", async () => { + const secretRef = { secretId: "prod/openai", jsonKey: "apiKey" }; + const subject = identity(); + const input = await project([{ ...API_KEY, secretRef }]); + + await run(subject.provision, input); + + expect(subject.calls).toEqual([ + { kind: "getApiKey", input: "openai-key" }, + { kind: "createApiKey", input: { name: "openai-key", secretRef } }, + ]); + }); + + test("names the variable and file to fix when an API key secret is missing", async () => { + const subject = identity(); + const input = await project([API_KEY]); + + await expect(run(subject.provision, input)).rejects.toThrow( + new RegExp( + `AGENTCORE_CREDENTIAL_OPENAI_KEY[\\s\\S]*${join(input.rootPath, "agentcore", ".env.local").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*secretRef`, + ), + ); + }); + + test("reuses a provider that already exists instead of recreating it", async () => { + const existing = { credentialProviderArn: "arn:existing", clientSecretArn: "arn:existing/s" }; + const subject = identity({ "openai-key": existing }); + const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n"); + + const { result } = await run(subject.provision, input); + + expect(subject.calls).toEqual([{ kind: "getApiKey", input: "openai-key" }]); + expect(result).toEqual({ "openai-key": existing }); + }); + + test("creates a guided OAuth2 provider without forwarding scopes", async () => { + const subject = identity(); + const input = await project([OAUTH], "AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_SECRET='shh'\n"); + + const { result } = await run(subject.provision, input); + + expect(subject.calls).toEqual([ + { kind: "getOauth2", input: "my-oauth" }, + { + kind: "createOauth2", + input: { + name: "my-oauth", + vendor: "CustomOauth2", + config: { + customOauth2ProviderConfig: { + oauthDiscovery: { discoveryUrl: DISCOVERY }, + clientId: "client-1", + clientSecret: "shh", + }, + }, + }, + }, + ]); + expect(result["my-oauth"]).toEqual({ + credentialProviderArn: "arn:oauth:my-oauth", + clientSecretArn: "arn:secret:my-oauth", + }); + }); + + test("falls back to the legacy _CLIENT_ID variable when the spec has no clientId", async () => { + const subject = identity(); + // An older CLI kept the client id in .env.local, not agentcore.json. + const { clientId: _dropped, ...withoutClientId } = OAUTH; + const input = await project( + [withoutClientId], + "AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_SECRET='shh'\n" + + "AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_ID='legacy-client'\n", + ); + + await run(subject.provision, input); + + expect(subject.calls[1]).toEqual({ + kind: "createOauth2", + input: { + name: "my-oauth", + vendor: "CustomOauth2", + config: { + customOauth2ProviderConfig: { + oauthDiscovery: { discoveryUrl: DISCOVERY }, + clientId: "legacy-client", + clientSecret: "shh", + }, + }, + }, + }); + }); + + test("injects the secret into a spec-supplied provider config", async () => { + const subject = identity(); + const input = await project( + [ + { + authorizerType: "OAuthCredentialProvider", + name: "vendored", + vendor: "GoogleOauth2", + providerConfig: { + googleOauth2ProviderConfig: { clientId: "google-client" }, + }, + }, + ], + "AGENTCORE_CREDENTIAL_VENDORED_CLIENT_SECRET='g-secret'\n", + ); + + await run(subject.provision, input); + + expect(subject.calls[1]).toEqual({ + kind: "createOauth2", + input: { + name: "vendored", + vendor: "GoogleOauth2", + config: { + googleOauth2ProviderConfig: { clientId: "google-client", clientSecret: "g-secret" }, + }, + }, + }); + }); + + test("passes an OAuth secret reference through as an external secret", async () => { + const clientSecretRef = { secretId: "prod/oauth", jsonKey: "clientSecret" }; + const subject = identity(); + const input = await project([{ ...OAUTH, clientSecretRef }]); + + await run(subject.provision, input); + + expect(subject.calls[1]).toEqual({ + kind: "createOauth2", + input: { + name: "my-oauth", + vendor: "CustomOauth2", + config: { + customOauth2ProviderConfig: { + oauthDiscovery: { discoveryUrl: DISCOVERY }, + clientId: "client-1", + clientSecretConfig: clientSecretRef, + clientSecretSource: "EXTERNAL", + }, + }, + }, + }); + }); + + test("rejects a provider config that is not a single vendor object", async () => { + const subject = identity(); + const input = await project( + [ + { + authorizerType: "OAuthCredentialProvider", + name: "two-vendors", + vendor: "GoogleOauth2", + providerConfig: { + googleOauth2ProviderConfig: { clientId: "a" }, + githubOauth2ProviderConfig: { clientId: "b" }, + }, + }, + ], + "AGENTCORE_CREDENTIAL_TWO_VENDORS_CLIENT_SECRET='s'\n", + ); + + await expect(run(subject.provision, input)).rejects.toThrow(/exactly one vendor config object/); + }); + + test("rejects a payment credential before creating any provider", async () => { + const subject = identity(); + const input = await project( + [ + API_KEY, + { authorizerType: "PaymentCredentialProvider", name: "pay-1", provider: "StripePrivy" }, + ], + "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n", + ); + + await expect(run(subject.provision, input)).rejects.toThrow( + /PaymentCredentialProvider, which 'agentcore project deploy' cannot create/, + ); + expect(subject.calls).toEqual([]); + }); + + test("creates nothing when a later credential's secret is missing", async () => { + const subject = identity(); + // First credential's secret is present; the second's is not. + const input = await project( + [API_KEY, { authorizerType: "ApiKeyCredentialProvider", name: "other-key" }], + "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n", + ); + + await expect(run(subject.provision, input)).rejects.toThrow(/AGENTCORE_CREDENTIAL_OTHER_KEY/); + // Both looked up, but no provider was created — the missing secret is caught + // before the first create, so there is no half-provisioned AWS state. + expect(subject.calls.map((c) => c.kind)).toEqual(["getApiKey", "getApiKey"]); + }); +}); diff --git a/src/core/project/backends/cdk/credentials.ts b/src/core/project/backends/cdk/credentials.ts new file mode 100644 index 000000000..27c2ca2bf --- /dev/null +++ b/src/core/project/backends/cdk/credentials.ts @@ -0,0 +1,356 @@ +import { join } from "node:path"; +import type { Oauth2ProviderConfigInput } from "@aws-sdk/client-bedrock-agentcore-control"; +import { MalformedServiceResponseError, ProjectStateError } from "../../../../errors/errors"; +import type { Project, ProjectEvent } from "../../../../handlers/project/types"; +import type { + ApiKeyCredential, + Credential, + OAuthCredential, + SecretReference, +} from "../../../../projectSchemas/credential"; +import { credentialEnvVarName } from "../../../../projectSchemas/credential"; +import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "../../envLocal"; +import type { CdkCredentialProvider } from "./toolkit"; + +/** A provisioned provider, in the shape the synthesized CDK app reads back. */ +export type DeployedCredential = { + credentialProviderArn: string; + clientSecretArn?: string; +}; +export type DeployedCredentials = Record; + +export type ApiKeyProviderInput = { + name: string; + /** Inline key material; mutually exclusive with `secretRef`. */ + apiKey?: string; + /** An existing Secrets Manager secret the customer manages themselves. */ + secretRef?: SecretReference; +}; + +export type Oauth2ProviderInput = { + name: string; + vendor: string; + config: Oauth2ProviderConfigInput; +}; + +/** The Identity calls provisioning needs — four methods, so tests inject a fake instead of the SDK client. */ +export type IdentityProviderClient = { + getApiKeyProvider(name: string): Promise; + createApiKeyProvider(input: ApiKeyProviderInput): Promise; + getOauth2Provider(name: string): Promise; + createOauth2Provider(input: Oauth2ProviderInput): Promise; +}; + +export type IdentityProviderClientFactory = ( + region: string, + credentials: CdkCredentialProvider, +) => Promise; + +export type CredentialProvisionInput = { + region: string; + /** Credential provider shared with the rest of the deployment preflight. */ + credentials: CdkCredentialProvider; +}; + +export type CredentialProvisioner = ( + project: Project, + input: CredentialProvisionInput, +) => AsyncGenerator; + +/** + * Builds an Identity client for the target's credentials. The SDK is imported + * lazily so projects without credentials never pay to load it. + */ +export const createIdentityProviderClient: IdentityProviderClientFactory = async ( + region, + credentials, +) => { + const { + BedrockAgentCoreControlClient, + CreateApiKeyCredentialProviderCommand, + CreateOauth2CredentialProviderCommand, + GetApiKeyCredentialProviderCommand, + GetOauth2CredentialProviderCommand, + ResourceNotFoundException, + } = await import("@aws-sdk/client-bedrock-agentcore-control"); + const client = new BedrockAgentCoreControlClient({ credentials, region }); + + // A missing provider is the normal first-deploy case, not a failure. + const undefinedWhenAbsent = async (send: () => Promise): Promise => { + try { + return await send(); + } catch (error) { + if (error instanceof ResourceNotFoundException) return undefined; + throw error; + } + }; + + return { + async getApiKeyProvider(name) { + const response = await undefinedWhenAbsent(() => + client.send(new GetApiKeyCredentialProviderCommand({ name })), + ); + if (!response) return undefined; + return { + credentialProviderArn: requireArn(response.credentialProviderArn, name), + ...(response.apiKeySecretArn?.secretArn && { + clientSecretArn: response.apiKeySecretArn.secretArn, + }), + }; + }, + async createApiKeyProvider({ name, apiKey, secretRef }) { + const response = await client.send( + new CreateApiKeyCredentialProviderCommand({ + name, + ...(apiKey !== undefined && { apiKey }), + ...(secretRef && { apiKeySecretConfig: secretRef, apiKeySecretSource: "EXTERNAL" }), + }), + ); + return { + credentialProviderArn: requireArn(response.credentialProviderArn, name), + ...(response.apiKeySecretArn?.secretArn && { + clientSecretArn: response.apiKeySecretArn.secretArn, + }), + }; + }, + async getOauth2Provider(name) { + const response = await undefinedWhenAbsent(() => + client.send(new GetOauth2CredentialProviderCommand({ name })), + ); + if (!response) return undefined; + return { + credentialProviderArn: requireArn(response.credentialProviderArn, name), + ...(response.clientSecretArn?.secretArn && { + clientSecretArn: response.clientSecretArn.secretArn, + }), + }; + }, + async createOauth2Provider({ name, vendor, config }) { + const response = await client.send( + new CreateOauth2CredentialProviderCommand({ + name, + // The spec's vendor is free-form so a new service vendor works without + // a CLI release; the service rejects values it does not know. + credentialProviderVendor: vendor as never, + oauth2ProviderConfigInput: config, + }), + ); + return { + credentialProviderArn: requireArn(response.credentialProviderArn, name), + ...(response.clientSecretArn?.secretArn && { + clientSecretArn: response.clientSecretArn.secretArn, + }), + }; + }, + }; +}; + +/** + * Provisions the credential providers a project declares, before synthesis: the + * synthesized app reads their ARNs from `deployed-state.json`, so a project with + * credentials can't synthesize until they exist. + * + * Created when absent, reused when present, never updated — so a redeploy neither + * mints a new secret version nor overwrites one rotated outside the CLI. + * Reconciling a changed declaration is left to a later change. + */ +export function createCredentialProvisioner( + createClient: IdentityProviderClientFactory = createIdentityProviderClient, +): CredentialProvisioner { + return async function* provisionCredentials(project, { region, credentials }) { + const declared = project.spec.credentials; + if (declared.length === 0) return {}; + + // Rejected up front so an unsupported credential fails before any AWS call. + const payment = declared.find((c) => c.authorizerType === "PaymentCredentialProvider"); + if (payment) throw paymentUnsupported(payment.name); + + const env = await new EnvLocalFile(project.rootPath).read(); + const client = await createClient(region, credentials); + + // Resolve every credential before creating any: look up existing providers + // (reused as-is) and validate the secret for the rest. A missing secret then + // fails before the first provider is created, not partway through the list. + const plans: { name: string; provision: Provision }[] = []; + for (const credential of declared) { + plans.push({ + name: credential.name, + provision: await resolveCredential(client, credential, env, project.rootPath), + }); + } + + const provisioned: DeployedCredentials = {}; + for (const { name, provision } of plans) { + yield { message: `Preparing credential provider '${name}'` }; + provisioned[name] = "reuse" in provision ? provision.reuse : await provision.create(); + } + return provisioned; + }; +} + +/** An existing provider to reuse, or a creation deferred until every secret is validated. */ +type Provision = { reuse: DeployedCredential } | { create: () => Promise }; + +function resolveCredential( + client: IdentityProviderClient, + credential: Credential, + env: Record, + rootPath: string, +): Promise { + switch (credential.authorizerType) { + case "ApiKeyCredentialProvider": + return resolveApiKey(client, credential, env, rootPath); + case "OAuthCredentialProvider": + return resolveOauth2(client, credential, env, rootPath); + case "PaymentCredentialProvider": + // Unreachable: rejected before provisioning starts. + throw paymentUnsupported(credential.name); + } +} + +async function resolveApiKey( + client: IdentityProviderClient, + credential: ApiKeyCredential, + env: Record, + rootPath: string, +): Promise { + // Provider names are account-global, so one already in this account is reused. + const existing = await client.getApiKeyProvider(credential.name); + if (existing) return { reuse: existing }; + + const input: ApiKeyProviderInput = credential.secretRef + ? { name: credential.name, secretRef: credential.secretRef } + : { + name: credential.name, + apiKey: requireEnvSecret(credential.name, env, rootPath, "secretRef"), + }; + return { create: () => client.createApiKeyProvider(input) }; +} + +async function resolveOauth2( + client: IdentityProviderClient, + credential: OAuthCredential, + env: Record, + rootPath: string, +): Promise { + const existing = await client.getOauth2Provider(credential.name); + if (existing) return { reuse: existing }; + + const secret: Record = credential.clientSecretRef + ? { clientSecretConfig: credential.clientSecretRef, clientSecretSource: "EXTERNAL" } + : { + clientSecret: requireEnvSecret( + credential.name, + env, + rootPath, + "clientSecretRef", + "_CLIENT_SECRET", + ), + }; + // Projects created by older CLIs kept the client id in .env.local rather than + // agentcore.json, so fall back to that legacy variable when the spec has none. + const clientId = credential.clientId ?? env[credentialEnvVarName(credential.name, "_CLIENT_ID")]; + const config = credential.providerConfig + ? vendorConfigWithSecret(credential.name, credential.providerConfig, secret) + : guidedCustomConfig(credential, clientId, secret); + return { + create: () => + client.createOauth2Provider({ name: credential.name, vendor: credential.vendor, config }), + }; +} + +/** Reads a credential's secret from `.env.local`, throwing an actionable error when absent. */ +function requireEnvSecret( + name: string, + env: Record, + rootPath: string, + refField: "secretRef" | "clientSecretRef", + suffix = "", +): string { + const envKey = credentialEnvVarName(name, suffix); + const secret = env[envKey]; + if (!secret) throw missingSecret(name, envKey, refField, rootPath); + return secret; +} + +/** + * Injects the secret into a complete, spec-supplied vendor config. The spec + * keeps provider configs secret-free, so the one vendor key it carries is the + * only place the secret can go. + */ +function vendorConfigWithSecret( + name: string, + providerConfig: Record, + secret: Record, +): Oauth2ProviderConfigInput { + const entries = Object.entries(providerConfig); + const [configKey, vendorConfig] = entries[0] ?? []; + if ( + entries.length !== 1 || + !configKey || + typeof vendorConfig !== "object" || + vendorConfig === null || + Array.isArray(vendorConfig) + ) { + throw new ProjectStateError( + `Credential '${name}' has a providerConfig with ${entries.length} entries; it must hold ` + + `exactly one vendor config object (for example { "customOauth2ProviderConfig": { ... } }).`, + ); + } + return { [configKey]: { ...vendorConfig, ...secret } } as unknown as Oauth2ProviderConfigInput; +} + +function guidedCustomConfig( + credential: OAuthCredential, + clientId: string | undefined, + secret: Record, +): Oauth2ProviderConfigInput { + // The spec's schema requires discoveryUrl for a guided credential; this guards + // a spec written before that rule rather than a reachable state. + if (!credential.discoveryUrl) { + throw new ProjectStateError( + `Credential '${credential.name}' needs either a discoveryUrl or a providerConfig ` + + `to create its OAuth2 provider.`, + ); + } + // `scopes` is deliberately not forwarded: provider creation has no scopes + // field, and the spec's scopes are consumed where the credential is used. + return { + customOauth2ProviderConfig: { + oauthDiscovery: { discoveryUrl: credential.discoveryUrl }, + ...(clientId !== undefined && { clientId }), + ...secret, + }, + }; +} + +function missingSecret( + name: string, + envKey: string, + refField: "secretRef" | "clientSecretRef", + rootPath: string, +): ProjectStateError { + return new ProjectStateError( + `Credential '${name}' has no secret to create its provider with. Set ${envKey} in ` + + `${join(rootPath, ENV_LOCAL_RELATIVE_PATH)}, or give the credential a '${refField}' in ` + + `agentcore.json pointing at a secret you keep in AWS Secrets Manager.`, + ); +} + +function paymentUnsupported(name: string): ProjectStateError { + return new ProjectStateError( + `Credential '${name}' is a PaymentCredentialProvider, which 'agentcore project deploy' ` + + `cannot create: a payment provider needs vendor configuration (API key, wallet and ` + + `authorization secrets) that agentcore.json has no fields for. Remove it from the project ` + + `spec to deploy the rest of the project.`, + ); +} + +function requireArn(arn: string | undefined, name: string): string { + if (!arn) { + throw new MalformedServiceResponseError( + `Identity returned no credentialProviderArn for credential provider '${name}'`, + ); + } + return arn; +} diff --git a/src/core/project/envLocal.test.ts b/src/core/project/envLocal.test.ts index 37d8cb4ca..5743df482 100644 --- a/src/core/project/envLocal.test.ts +++ b/src/core/project/envLocal.test.ts @@ -95,3 +95,16 @@ test("rejects a value that contains a single quote", async () => { /single quote/, ); }); + +test("read returns {} when the file does not exist", async () => { + const root = await tempRoot(); + expect(await new EnvLocalFile(root).read()).toEqual({}); +}); + +test("read parses back the entries insertIfNew wrote", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + await file.insertIfNew([{ key: "SECRET", value: "s k", comment: "c" }]); + + expect(await file.read()).toEqual({ SECRET: "s k" }); +}); diff --git a/src/core/project/envLocal.ts b/src/core/project/envLocal.ts index 32143f3db..ba29b3699 100644 --- a/src/core/project/envLocal.ts +++ b/src/core/project/envLocal.ts @@ -1,5 +1,6 @@ import { chmod, rm } from "node:fs/promises"; import { join } from "node:path"; +import { parseEnv } from "node:util"; import { atomicWrite, readTextFile } from "../../io"; import { InputValidationError } from "../../errors"; import type { EnvLocalEntry } from "../../handlers/project/types"; @@ -65,6 +66,17 @@ export class EnvLocalFile { return { written, skipped }; } + /** + * Reads the file's entries as a key/value map, returning {} when the file + * does not exist. Values are read back with the same parser `agentcore dev` + * uses, so quoting written by {@link insertIfNew} round-trips. + */ + async read(): Promise> { + const content = await this.readOrNull(); + if (content === null) return {}; + return parseEnv(content); + } + /** Restores the file to its pre-write state; a no-op when nothing was written. */ async rollback(): Promise { if (this.snapshot === undefined) return; diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 9c642432d..e83062480 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -769,6 +769,15 @@ describe("project add credentials", () => { ); }); + test("rejects different credential types that collide on one secret variable", async () => { + await inProject(); + // OAuth 'foo' → AGENTCORE_CREDENTIAL_FOO_CLIENT_SECRET; api-key 'foo_client_secret' → the same. + await run(["add", "credentials", "oauth", "--name", "foo", "--discovery-url", discoveryUrl]); + await expect( + run(["add", "credentials", "api-key", "--name", "foo_client_secret"]), + ).rejects.toThrow(/same environment variable/); + }); + test.each<[string, string[], RegExp]>([ [ "api-key: an inline secret value",