Skip to content

Commit f98a0ff

Browse files
authored
feat(project): scope Identity credential providers by deployment target (#2332)
* feat(project): scope Identity credential providers by deployment target Each credential now creates an Identity provider named <project>_<target>_<credential> so the same spec deploys to several targets in one account. Teardown deletes every provider kind under its scoped name and reports the AWS CLI command for one it could not remove. Scaffolded and exported agents read the provider name from AGENTCORE_CREDENTIAL_<NAME>_NAME and fall back to the spec name. * refactor: apply simplify findings - Drop ARN_SEGMENT_KINDS and recordedKind: a recorded entry without authorizerType was provisioned under the bare name, so the scoped delete could never find it. Teardown now skips such entries and the comments say so. - Use the exported CredentialType alias at every provider kind site in credentials.ts. - Set PROVIDER_NAME_MAX_LENGTH to the Identity limit directly instead of reading it off CredentialNameSchema. - Assert the load.py provider name with credentialEnvVarName in project.test.ts instead of restating the naming rule. - Inline targetName: "dev" in credentials.test.ts and remove the one-use TARGET const. - Remove the ARN segment classification test.each and its ARN_PREFIX const, and reword the skip test for the missing authorizerType branch. * fix(project): reject credential names that would shadow the provider name variable A credential named svc-name writes AGENTCORE_CREDENTIAL_SVC_NAME, the variable that carries credential svc's provider name. * fix(project): remove a target's provider when its credential leaves the spec
1 parent 4bfb572 commit f98a0ff

13 files changed

Lines changed: 515 additions & 366 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,23 @@ Source-aware values: any field flag documented as such accepts the value inline,
477477
`file://` convention). A command reads stdin from at most one flag. For example,
478478
`--instructions file://order-quality.txt` or `--instructions -`.
479479

480+
Project credentials: a `credentials[]` entry in `agentcore.json` is named by its
481+
spec name and keeps its secret in `agentcore/.env.local` under
482+
`AGENTCORE_CREDENTIAL_<NAME>` (with a field suffix for OAuth2 and payment
483+
values). `project deploy` provisions the Identity credential provider before
484+
synth under the name `<project>_<target>_<credential>`, so two targets in one
485+
account and region get separate providers, and records its ARN in
486+
`deployed-state.json` under the spec name. A credential removed from the spec
487+
has its provider deleted on the next deploy of each target, after the stack
488+
update. Tearing a target down (deploying a spec with nothing left to deploy,
489+
which is where `project remove all` leads) deletes every provider the target
490+
owns: API key, OAuth2 and payment. CLI versions before this change named providers by the bare credential name. Those
491+
providers stay in the account after an upgrade, untouched by deploys and
492+
teardowns. Delete them with `agentcore identity api-key-credential-provider
493+
delete --name <credential>`, the `oauth2-credential-provider` equivalent, or
494+
`aws bedrock-agentcore-control delete-payment-credential-provider` once nothing
495+
else uses them.
496+
480497
### Extending the CDK app
481498

482499
`agentcore/cdk/` is a CDK app of two files. `bin/cdk.ts` reads the project once

‎src/assets/templates/agent-python-strands/model/load.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def load_model() -> BedrockModel:
1212
from strands.models.anthropic import AnthropicModel
1313
from bedrock_agentcore.identity.auth import requires_api_key
1414

15-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
15+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
1616
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
1717

1818

@@ -51,7 +51,7 @@ def load_model() -> AnthropicModel:
5151
from strands.models.openai import OpenAIModel
5252
from bedrock_agentcore.identity.auth import requires_api_key
5353

54-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
54+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
5555
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
5656

5757

@@ -89,7 +89,7 @@ def load_model() -> OpenAIModel:
8989
from strands.models.gemini import GeminiModel
9090
from bedrock_agentcore.identity.auth import requires_api_key
9191

92-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
92+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
9393
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
9494

9595

@@ -130,7 +130,7 @@ def load_model() -> GeminiModel:
130130
{{#if identityProviders.[0].name}}
131131
from bedrock_agentcore.identity.auth import requires_api_key
132132

133-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
133+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
134134
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
135135

136136

‎src/assets/templates/export-harness-python/model/load.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def load_model() -> BedrockModel:
8989
{{/if}}
9090
from bedrock_agentcore.identity.auth import requires_api_key
9191

92-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
92+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
9393
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
9494

9595

@@ -138,7 +138,7 @@ def load_model():
138138
from strands.models.gemini import GeminiModel
139139
from bedrock_agentcore.identity.auth import requires_api_key
140140

141-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
141+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
142142
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
143143

144144

@@ -194,7 +194,7 @@ def load_model() -> GeminiModel:
194194
{{#if identityProviders.[0].name}}
195195
from bedrock_agentcore.identity.auth import requires_api_key
196196

197-
IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}"
197+
IDENTITY_PROVIDER_NAME = os.environ.get("{{identityProviders.[0].envVarName}}_NAME", "{{identityProviders.[0].name}}")
198198
IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}"
199199

200200

‎src/core/project/backends/cdk.test.ts‎

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { createSilentLogger } from "../../../testing";
1010
import { CdkBackend } from "./cdk";
1111
import type {
1212
CredentialProviderCalls,
13+
CredentialProvisionInput,
1314
CredentialProvisioner,
14-
DeployedCredentials,
15-
PaymentCredentialRemover,
15+
CredentialRemovalInput,
16+
CredentialRemover,
1617
} from "./cdk/credentials";
1718
import { DEPLOYED_STATE_RELATIVE_PATH, updateTargetState } from "./cdk/deployedState";
1819
import type { DeployBackendInput } from "./types";
@@ -157,7 +158,7 @@ type HarnessOptions = {
157158
failOperation?: CdkOperation["kind"];
158159
bootstrapError?: Error;
159160
provisionCredentials?: CredentialProvisioner;
160-
removePaymentCredentials?: PaymentCredentialRemover;
161+
removeCredentials?: CredentialRemover;
161162
/** Stack returned by CloudFormation. Defaults to a present stack; null means absent. */
162163
describedStack?: Stack | null;
163164
/** Chunks the fake synth process streams through onOutput. */
@@ -237,8 +238,8 @@ function harness(options: HarnessOptions = {}) {
237238
};
238239
},
239240
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
240-
...(options.removePaymentCredentials && {
241-
removePaymentCredentials: options.removePaymentCredentials,
241+
...(options.removeCredentials && {
242+
removeCredentials: options.removeCredentials,
242243
}),
243244
describeStack: async (region, provider, stackName) => {
244245
stackReads.push({ stackName, region, credentials: provider });
@@ -453,10 +454,12 @@ describe("CdkBackend.deploy", () => {
453454
});
454455
});
455456

456-
test("provisions credentials before synth and records them under the target", async () => {
457+
test("provisions credentials for the target before synth and records them under it", async () => {
457458
const input = await project();
458459
await writeAssembly(input, [TARGET.name]);
459-
const provisionCredentials: CredentialProvisioner = async function* () {
460+
const handed: CredentialProvisionInput[] = [];
461+
const provisionCredentials: CredentialProvisioner = async function* (_project, provisionInput) {
462+
handed.push(provisionInput);
460463
yield { type: "step", message: "Preparing credential provider 'openai-key'" };
461464
return { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } };
462465
};
@@ -468,6 +471,10 @@ describe("CdkBackend.deploy", () => {
468471

469472
const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));
470473

474+
// The target's name scopes the provider names, so provisioning needs it.
475+
expect(handed).toEqual([
476+
{ credentials: subject.credentials, region: TARGET.region, targetName: TARGET.name },
477+
]);
471478
// The credential step runs (and its ARNs are recorded) before synthesis, so
472479
// the assembly is synthesized against a state file that already describes them.
473480
const messages = stepMessages(deployed.events);
@@ -640,15 +647,15 @@ describe("CdkBackend.deploy", () => {
640647
});
641648
});
642649

643-
test("removes the project's payment credential providers after its stack", async () => {
650+
test("removes the target's credential providers after its stack", async () => {
644651
const input = await project();
645652
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
646653
const removals: string[] = [];
647-
const removePaymentCredentials: PaymentCredentialRemover = async function* (project) {
654+
const removeCredentials: CredentialRemover = async function* (project) {
648655
removals.push(project.name);
649656
yield { type: "step", message: "Removing credential provider 'wallet'" };
650657
};
651-
const subject = harness({ removePaymentCredentials });
658+
const subject = harness({ removeCredentials });
652659

653660
const deployed = await collectDeploy(
654661
subject.backend.deploy(input, deployInput({ confirmTeardown: async () => true })),
@@ -662,10 +669,11 @@ describe("CdkBackend.deploy", () => {
662669
);
663670
});
664671

665-
test("hands teardown the credentials recorded before the deploy overwrote them", async () => {
672+
test("hands teardown the target and the providers recorded before the deploy overwrote them", async () => {
666673
// The `project remove all` shape: the spec declares nothing, so provisioning
667674
// returns nothing and rewrites the credentials map to empty before teardown runs.
668-
// The recorded providers are the only remaining record of what to delete.
675+
// The recorded providers are the only remaining record of what to delete, and
676+
// the target name is what scopes their provider names.
669677
const input = await project();
670678
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
671679
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
@@ -686,21 +694,88 @@ describe("CdkBackend.deploy", () => {
686694
}),
687695
);
688696

689-
const handed: DeployedCredentials[] = [];
690-
const removePaymentCredentials: PaymentCredentialRemover = async function* (
691-
_project,
692-
{ recorded },
693-
) {
694-
handed.push(recorded);
697+
const handed: CredentialRemovalInput[] = [];
698+
const removeCredentials: CredentialRemover = async function* (_project, removalInput) {
699+
handed.push(removalInput);
695700
yield { type: "step", message: "Removing credential provider 'wallet'" };
696701
};
697-
const subject = harness({ removePaymentCredentials });
702+
const subject = harness({ removeCredentials });
698703

699704
await collectDeploy(
700705
subject.backend.deploy(input, deployInput({ confirmTeardown: async () => true })),
701706
);
702707

703-
expect(handed).toEqual([{ wallet: recordedWallet }]);
708+
expect(handed).toEqual([
709+
{
710+
credentials: subject.credentials,
711+
region: TARGET.region,
712+
targetName: TARGET.name,
713+
providers: [{ name: "wallet", authorizerType: "PaymentCredentialProvider" }],
714+
},
715+
]);
716+
});
717+
718+
test("removes a recorded provider whose credential left the spec, after the stack update", async () => {
719+
const input = await project();
720+
input.spec = ProjectSpecSchema.parse({
721+
name: "example",
722+
version: 1,
723+
credentials: [{ authorizerType: "ApiKeyCredentialProvider", name: "openai-key" }],
724+
});
725+
await writeAssembly(input, [TARGET.name]);
726+
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
727+
await mkdir(dirname(statePath), { recursive: true });
728+
await writeFile(
729+
statePath,
730+
JSON.stringify({
731+
targets: {
732+
default: {
733+
stackArn: "arn:stack:default",
734+
resources: {
735+
credentials: {
736+
"openai-key": {
737+
credentialProviderArn: "arn:apikey:openai-key",
738+
authorizerType: "ApiKeyCredentialProvider",
739+
},
740+
wallet: {
741+
credentialProviderArn: "arn:payment:wallet",
742+
authorizerType: "PaymentCredentialProvider",
743+
},
744+
},
745+
},
746+
},
747+
},
748+
}),
749+
);
750+
const provisionCredentials: CredentialProvisioner = async function* () {
751+
yield { type: "step", message: "Preparing credential provider 'example_default_openai-key'" };
752+
return { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } };
753+
};
754+
const handed: CredentialRemovalInput[] = [];
755+
const removeCredentials: CredentialRemover = async function* (_project, removalInput) {
756+
handed.push(removalInput);
757+
yield { type: "step", message: "Removing credential provider 'example_default_wallet'" };
758+
};
759+
const subject = harness({ provisionCredentials, removeCredentials });
760+
761+
const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));
762+
763+
// Only the dropped credential's provider goes: the declared one was just provisioned.
764+
expect(handed).toEqual([
765+
{
766+
credentials: subject.credentials,
767+
region: TARGET.region,
768+
targetName: TARGET.name,
769+
providers: [{ name: "wallet", authorizerType: "PaymentCredentialProvider" }],
770+
},
771+
]);
772+
// After the stack update, since a resource in the stack may have used the
773+
// provider until this deploy removed the reference.
774+
const messages = stepMessages(deployed.events);
775+
expect(messages.indexOf("Deploying AgentCore-example-default-0")).toBeLessThan(
776+
messages.indexOf("Removing credential provider 'example_default_wallet'"),
777+
);
778+
expect(deployed.result).toEqual({ outputs: {} });
704779
});
705780

706781
test("says to add a resource when there is no stack to remove either", async () => {

0 commit comments

Comments
 (0)