Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/core/agentCorePolicyGrants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ export class AgentCorePolicyGrants {
return allow(["bedrock-agentcore:InvokeGateway"], [gatewayArn]);
}

static getWorkloadAccessToken(workloadArns: readonly string[]): GeneratedPolicyStatement {
return allow(["bedrock-agentcore:GetWorkloadAccessToken"], workloadArns);
}

static getResourceApiKey(providerArn: string): GeneratedPolicyStatement {
return allow(["bedrock-agentcore:GetResourceApiKey"], [providerArn]);
}

static getResourceOauth2Token(providerArn: string): GeneratedPolicyStatement {
return allow(["bedrock-agentcore:GetResourceOauth2Token"], [providerArn]);
}

static readSecret(secretArn: string): GeneratedPolicyStatement {
return allow(["secretsmanager:GetSecretValue"], [secretArn]);
}

static invokeRuntime(runtimeArns: readonly string[]): GeneratedPolicyStatement {
return allow(["bedrock-agentcore:InvokeAgentRuntime"], runtimeArns);
}
Expand Down Expand Up @@ -85,6 +101,30 @@ export class AgentCorePolicyGrants {
return allow(["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], [knowledgeBaseArn]);
}

static getKnowledgeBases(knowledgeBaseArns: readonly string[]): GeneratedPolicyStatement {
return allow(["bedrock:GetKnowledgeBase"], knowledgeBaseArns);
}

static retrieveKnowledgeBases(knowledgeBaseArns: readonly string[]): GeneratedPolicyStatement {
return allow(["bedrock:Retrieve"], knowledgeBaseArns);
}

static agenticRetrieveKnowledgeBases(): GeneratedPolicyStatement {
return allow(["bedrock:AgenticRetrieveStream"], ["*"]);
}

static createMantleInference(projectArns: readonly string[]): GeneratedPolicyStatement {
return allow(["bedrock-mantle:CreateInference"], projectArns);
}

static listMantleModels(projectArn: string): GeneratedPolicyStatement {
return allow(["bedrock-mantle:ListModels"], [projectArn]);
}

static callMantleWithBearerToken(): GeneratedPolicyStatement {
return allow(["bedrock-mantle:CallWithBearerToken"], ["*"]);
}

static decryptKmsKey(keyArn: string): GeneratedPolicyStatement {
return allow(["kms:Decrypt", "kms:DescribeKey"], [keyArn]);
}
Expand Down
50 changes: 50 additions & 0 deletions src/core/executionRoleManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,56 @@ describe("ExecutionRoleManager role lifecycle", () => {
}
});

test("accepts standard trust conditions when the Gateway context matches", async () => {
const gatewayArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123";
const iam = {
send: async () => ({
Role: {
Arn: roleArn("AmazonBedrockAgentCoreGatewayDefaultServiceRole"),
RoleName: "AmazonBedrockAgentCoreGatewayDefaultServiceRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "bedrock-agentcore.amazonaws.com" },
Action: "sts:AssumeRole",
Condition: {
StringEquals: { "aws:SourceAccount": ACCOUNT },
ArnLike: {
"aws:SourceArn": "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/*",
},
},
},
],
}),
},
}),
} as unknown as IAMClient;

await expect(
new ExecutionRoleManager(iam).validateAgentCoreTrust(
"AmazonBedrockAgentCoreGatewayDefaultServiceRole",
{
sourceAccount: ACCOUNT,
sourceArn: gatewayArn,
},
),
).resolves.toMatchObject({
arn: roleArn("AmazonBedrockAgentCoreGatewayDefaultServiceRole"),
created: false,
});
await expect(
new ExecutionRoleManager(iam).validateAgentCoreTrust(
"AmazonBedrockAgentCoreGatewayDefaultServiceRole",
{
sourceAccount: "000000000000",
sourceArn: gatewayArn,
},
),
).rejects.toBeInstanceOf(ExecutionRoleTrustError);
});

test("rolls back only a role created by the current parent create", async () => {
const sent: (DeleteRoleCommand | DeleteRolePolicyCommand)[] = [];
const iam = {
Expand Down
63 changes: 57 additions & 6 deletions src/core/executionRoleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ export type ExecutionRoleManagerOptions = {
sleep?: (milliseconds: number) => Promise<void>;
};

export type AgentCoreTrustContext = {
sourceAccount: string;
sourceArn: string;
};

export class ExecutionRoleTrustError extends Error {
constructor(readonly roleName: string) {
super(`IAM role ${roleName} does not allow bedrock-agentcore.amazonaws.com to assume it.`);
Expand Down Expand Up @@ -172,9 +177,12 @@ export class ExecutionRoleManager {
return { arn: roleArn, name: roleName, created: true };
}

async validateAgentCoreTrust(roleName: string): Promise<ManagedExecutionRole> {
async validateAgentCoreTrust(
roleName: string,
context?: AgentCoreTrustContext,
): Promise<ManagedExecutionRole> {
const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName }));
return this.existingRole(response.Role, roleName);
return this.existingRole(response.Role, roleName, context);
}

async rollbackCreatedRole(
Expand Down Expand Up @@ -232,13 +240,17 @@ export class ExecutionRoleManager {
}
}

private existingRole(role: Role | undefined, expectedRoleName: string): ManagedExecutionRole {
private existingRole(
role: Role | undefined,
expectedRoleName: string,
context?: AgentCoreTrustContext,
): ManagedExecutionRole {
const roleArn = requiredRoleArn(role, expectedRoleName);
let trusted = false;
try {
trusted =
role?.AssumeRolePolicyDocument !== undefined &&
allowsAgentCoreAssumeRole(role.AssumeRolePolicyDocument);
allowsAgentCoreAssumeRole(role.AssumeRolePolicyDocument, context);
} catch {
trusted = false;
}
Expand Down Expand Up @@ -297,7 +309,10 @@ function requiredRoleArn(role: Role | undefined, roleName: string): string {
return role.Arn;
}

function allowsAgentCoreAssumeRole(policyDocument: string): boolean {
function allowsAgentCoreAssumeRole(
policyDocument: string,
context?: AgentCoreTrustContext,
): boolean {
const policy = parseIamDocument(policyDocument);
const statements = Array.isArray(policy.Statement) ? policy.Statement : [policy.Statement];
const applicable = statements.filter((statement) => {
Expand All @@ -313,10 +328,46 @@ function allowsAgentCoreAssumeRole(policyDocument: string): boolean {
});
if (applicable.some((statement) => statement.Effect === "Deny")) return false;
return applicable.some(
(statement) => statement.Effect === "Allow" && statement.Condition === undefined,
(statement) =>
statement.Effect === "Allow" &&
(statement.Condition === undefined || trustConditionsMatch(statement.Condition, context)),
);
}

function trustConditionsMatch(
condition: unknown,
context: AgentCoreTrustContext | undefined,
): boolean {
if (!context || !isRecord(condition)) return false;

for (const [operator, entries] of Object.entries(condition)) {
if (!isRecord(entries)) return false;
for (const [key, expected] of Object.entries(entries)) {
const normalizedKey = key.toLowerCase();
const actual =
normalizedKey === "aws:sourceaccount"
? context.sourceAccount
: normalizedKey === "aws:sourcearn"
? context.sourceArn
: undefined;
if (!actual) return false;
const expectedValues = stringList(expected);
if (expectedValues.length === 0) return false;
if (operator === "StringEquals" || operator === "ArnEquals") {
if (!expectedValues.includes(actual)) return false;
continue;
}
if (operator === "StringLike" || operator === "ArnLike") {
if (!expectedValues.some((pattern) => iamGlobMatches(pattern, actual))) return false;
continue;
}
return false;
}
}

return true;
}

function statementAppliesToAssumeRole(statement: Record<string, unknown>): boolean {
if (statement.Action !== undefined) {
return stringList(statement.Action).some((pattern) =>
Expand Down
78 changes: 78 additions & 0 deletions src/core/executionRolePolicyUpdater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
PolicyDriftError,
PolicyFinalizationError,
PolicyOperationOutcomeUnknownError,
PolicyRemovalFinalizationError,
PolicyRemovalOutcomeUnknownError,
RoleInlinePolicyQuotaError,
} from "./executionRolePolicyUpdater";

Expand Down Expand Up @@ -494,6 +496,82 @@ describe("ExecutionRolePolicyUpdater", () => {
expect(writes).toHaveLength(1);
});

test("removes a generated policy only after a parent deletion succeeds", async () => {
const events: string[] = [];
const { iam } = statefulIam(events);
const updater = new ExecutionRolePolicyUpdater(iam, {
propagationDelayMs: 0,
retryDelayMs: 0,
});

const value = await updater.removeAfter({
roleName: ROLE_NAME,
policyName: POLICY_NAME,
operation: async () => {
events.push("AgentCoreDelete");
return { gatewayId: "gateway-1" };
},
});

expect(value).toEqual({ gatewayId: "gateway-1" });
expect(events).toEqual(["AgentCoreDelete", "DeleteRolePolicyCommand", "GetRolePolicyCommand"]);
});

test("retains the generated policy when parent deletion outcome is unknown", async () => {
const events: string[] = [];
const { iam } = statefulIam(events);
const updater = new ExecutionRolePolicyUpdater(iam, {
propagationDelayMs: 0,
retryDelayMs: 0,
});
const timeout = new Error("delete response timed out");
timeout.name = "GatewayOutcomeUnknownError";

const error = await updater
.removeAfter({
roleName: ROLE_NAME,
policyName: POLICY_NAME,
operation: async () => {
events.push("AgentCoreDelete");
throw timeout;
},
isOperationOutcomeUnknown: (caught) =>
(caught as Error).name === "GatewayOutcomeUnknownError",
})
.catch((caught) => caught);

expect(error).toBeInstanceOf(PolicyRemovalOutcomeUnknownError);
expect((error as PolicyRemovalOutcomeUnknownError).cause).toBe(timeout);
expect(events).toEqual(["AgentCoreDelete"]);
});

test("reports exact manual repair coordinates when policy removal fails", async () => {
const iam = {
send: async (command: SentCommand) => {
if (command instanceof DeleteRolePolicyCommand) {
throw new Error("IAM denied cleanup");
}
throw new Error(`unexpected IAM command ${command.constructor.name}`);
},
} as unknown as IAMClient;
const updater = new ExecutionRolePolicyUpdater(iam, {
propagationDelayMs: 0,
retryDelayMs: 0,
});

const error = await updater
.removeAfter({
roleName: ROLE_NAME,
policyName: POLICY_NAME,
operation: async () => ({ gatewayId: "gateway-1" }),
})
.catch((caught) => caught);

expect(error).toBeInstanceOf(PolicyRemovalFinalizationError);
expect((error as Error).message).toContain(POLICY_NAME);
expect((error as Error).message).toContain(ROLE_NAME);
});

test("paginates customer policies and excludes insignificant JSON whitespace from quota", async () => {
const events: string[] = [];
let generatedPolicy: string | undefined;
Expand Down
67 changes: 67 additions & 0 deletions src/core/executionRolePolicyUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export type ExecutionRolePolicyUpdateResult<T> = {
tightened: boolean;
};

export type ExecutionRolePolicyRemoval<T> = {
roleName: string;
policyName: string;
operation: () => Promise<T>;
isOperationOutcomeUnknown?: (error: unknown) => boolean;
};

export class PolicyPropagationError extends Error {
constructor(
readonly roleName: string,
Expand Down Expand Up @@ -139,6 +146,38 @@ export class PolicyFinalizationError<T> extends Error {
}
}

export class PolicyRemovalOutcomeUnknownError extends Error {
constructor(
readonly roleName: string,
readonly policyName: string,
options: ErrorOptions,
) {
super(
"AgentCore deletion outcome is unknown; the generated execution-role policy was retained. " +
"Inspect the resource before retrying.",
options,
);
this.name = "PolicyRemovalOutcomeUnknownError";
}
}

export class PolicyRemovalFinalizationError<T> extends Error {
constructor(
readonly value: T,
readonly roleName: string,
readonly policyName: string,
options: ErrorOptions,
) {
const identity = resourceIdentity(value);
super(
`AgentCore deletion succeeded${identity ? ` for ${identity}` : ""}, but generated execution-role policy cleanup failed. ` +
`Remove policy ${policyName} from role ${roleName} manually; do not delete the role.`,
options,
);
this.name = "PolicyRemovalFinalizationError";
}
}

export class ExecutionRolePolicyUpdater {
private readonly compiler: PolicyCompiler;
private readonly maxVisibilityAttempts: number;
Expand All @@ -163,6 +202,34 @@ export class ExecutionRolePolicyUpdater {
return rolePolicyTransactions.run(request.roleName, () => this.updateUnlocked(request));
}

async removeAfter<T>(request: ExecutionRolePolicyRemoval<T>): Promise<T> {
return rolePolicyTransactions.run(request.roleName, () => this.removeAfterUnlocked(request));
}

private async removeAfterUnlocked<T>(request: ExecutionRolePolicyRemoval<T>): Promise<T> {
let value: T;
try {
value = await request.operation();
} catch (error) {
if (request.isOperationOutcomeUnknown?.(error)) {
throw new PolicyRemovalOutcomeUnknownError(request.roleName, request.policyName, {
cause: error,
});
}
throw error;
}

try {
await this.deletePolicy(request.roleName, request.policyName);
await this.waitUntilAbsent(request.roleName, request.policyName);
} catch (error) {
throw new PolicyRemovalFinalizationError(value, request.roleName, request.policyName, {
cause: error,
});
}
return value;
}

private async updateUnlocked<T>(
request: ExecutionRolePolicyUpdate<T>,
): Promise<ExecutionRolePolicyUpdateResult<T>> {
Expand Down
Loading
Loading