Skip to content

Commit c526e9b

Browse files
committed
fix(graphql-generation-transformer): grant InvokeModel on inference profile and underlying models
The @generation transformer builds an AppSync->Bedrock IAM role that scoped bedrock:InvokeModel to a single regional foundation-model ARN (arn:<partition>:bedrock:<region>::foundation-model/<modelId>). For a cross-region inference profile id (us./eu./apac. prefix) that ARN is wrong: invoking a profile requires InvokeModel on the inference-profile ARN AND on the underlying regional foundation-model ARNs the profile routes to. As a result, customers using a cross-region profile model id with @generation hit runtime AccessDenied. When the model id matches /^(us|eu|apac)\./ the role now grants InvokeModel on both the inference-profile ARN (arn:<partition>:bedrock:<region>:<account>:inference-profile/<modelId>) and the underlying foundation-model ARN with a wildcard region (arn:<partition>:bedrock:*::foundation-model/<foundationModelId>), where the underlying model id is the profile id with the region prefix stripped. Plain foundation-model ids keep the existing single-ARN behavior. All ARNs use CDK account/region/partition tokens. Added unit/snapshot tests proving a plain FM id grants only the foundation-model ARN (unchanged) and an inference-profile id grants both the profile ARN and the wildcard-region foundation-model ARN. The generation transformer unit tests run offline and all 16 pass. --- Prompt: Draft the OPTION A product fix as its own branch/PR. The @generation transformer scopes bedrock:InvokeModel to a single foundation-model ARN; for a cross-region inference profile id that ARN is wrong, so @generation + cross-region profile hits AccessDenied. Implement granting InvokeModel on both the inference-profile ARN and the underlying regional foundation-model ARNs (wildcard region), add tests, build, commit, push, and open a draft PR off main.
1 parent 9a2c742 commit c526e9b

3 files changed

Lines changed: 139 additions & 1 deletion

File tree

packages/amplify-graphql-generation-transformer/src/__tests__/__snapshots__/amplify-graphql-generation-transformer.test.ts.snap

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,62 @@ function createUserAgent(request) {
148148
}
149149
`;
150150
151+
exports[`generation route bedrock InvokeModel IAM resources cross-region inference profile id grants the profile ARN and wildcard-region foundation-model ARN 1`] = `
152+
[
153+
{
154+
"Fn::Join": [
155+
"",
156+
[
157+
"arn:",
158+
{
159+
"Ref": "AWS::Partition",
160+
},
161+
":bedrock:",
162+
{
163+
"Ref": "AWS::Region",
164+
},
165+
":",
166+
{
167+
"Ref": "AWS::AccountId",
168+
},
169+
":inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0",
170+
],
171+
],
172+
},
173+
{
174+
"Fn::Join": [
175+
"",
176+
[
177+
"arn:",
178+
{
179+
"Ref": "AWS::Partition",
180+
},
181+
":bedrock:*::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0",
182+
],
183+
],
184+
},
185+
]
186+
`;
187+
188+
exports[`generation route bedrock InvokeModel IAM resources plain foundation-model id grants the foundation-model ARN only 1`] = `
189+
{
190+
"Fn::Join": [
191+
"",
192+
[
193+
"arn:",
194+
{
195+
"Ref": "AWS::Partition",
196+
},
197+
":bedrock:",
198+
{
199+
"Ref": "AWS::Region",
200+
},
201+
"::foundation-model/anthropic.claude-3-haiku-20240307-v1:0",
202+
],
203+
],
204+
}
205+
`;
206+
151207
exports[`generation route custom query 1`] = `
152208
{
153209
"Fn::Join": [

packages/amplify-graphql-generation-transformer/src/__tests__/amplify-graphql-generation-transformer.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,55 @@ describe('generation route invalid inference configuration', () => {
326326
});
327327
// });
328328

329+
describe('generation route bedrock InvokeModel IAM resources', () => {
330+
const generationRoute = (aiModel: string): string => `
331+
type Query {
332+
generate(description: String!): String
333+
@generation(
334+
aiModel: "${aiModel}",
335+
systemPrompt: "Make a string based on the description."
336+
)
337+
}
338+
`;
339+
340+
const getBedrockInvokeResources = (out: DeploymentResources, fieldName: string): unknown => {
341+
const stackName = `GenerationBedrockDataSource${fieldName}Stack`;
342+
const stack = out.stacks[stackName];
343+
expect(stack).toBeDefined();
344+
345+
const role = Object.values(stack.Resources ?? {}).find(
346+
(resource: any) => resource.Type === 'AWS::IAM::Role' && resource.Properties?.Policies,
347+
) as any;
348+
expect(role).toBeDefined();
349+
350+
const statement = role.Properties.Policies[0].PolicyDocument.Statement.find(
351+
(s: any) => Array.isArray(s.Action) ? s.Action.includes('bedrock:InvokeModel') : s.Action === 'bedrock:InvokeModel',
352+
);
353+
expect(statement).toBeDefined();
354+
return statement.Resource;
355+
};
356+
357+
test('plain foundation-model id grants the foundation-model ARN only', () => {
358+
const out = transform(generationRoute('anthropic.claude-3-haiku-20240307-v1:0'));
359+
const resources = getBedrockInvokeResources(out, 'Generate');
360+
const serialized = JSON.stringify(resources);
361+
362+
expect(serialized).toContain('foundation-model/anthropic.claude-3-haiku-20240307-v1:0');
363+
expect(serialized).not.toContain('inference-profile');
364+
expect(resources).toMatchSnapshot();
365+
});
366+
367+
test('cross-region inference profile id grants the profile ARN and wildcard-region foundation-model ARN', () => {
368+
const out = transform(generationRoute('us.anthropic.claude-haiku-4-5-20251001-v1:0'));
369+
const resources = getBedrockInvokeResources(out, 'Generate');
370+
const serialized = JSON.stringify(resources);
371+
372+
expect(serialized).toContain('inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0');
373+
expect(serialized).toContain('foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0');
374+
expect(resources).toMatchSnapshot();
375+
});
376+
});
377+
329378
const getResolverResource = (queryName: string, resources?: Record<string, any>): Record<string, any> => {
330379
const resolverName = `Query${queryName}Resolver`;
331380
return resources?.[resolverName];

packages/amplify-graphql-generation-transformer/src/grapqhl-generation-transformer.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,14 +207,47 @@ export class GenerationTransformer extends TransformerPluginBase {
207207
new iam.PolicyStatement({
208208
effect: iam.Effect.ALLOW,
209209
actions: ['bedrock:InvokeModel'],
210-
resources: [`arn:${cdk.Stack.of(dataSourceScope).partition}:bedrock:${region}::foundation-model/${bedrockModelId}`],
210+
resources: this.bedrockInvokeModelResources(dataSourceScope, region, bedrockModelId),
211211
}),
212212
],
213213
}),
214214
},
215215
});
216216
}
217217

218+
/**
219+
* Builds the list of resource ARNs that `bedrock:InvokeModel` is granted on for a given model id.
220+
*
221+
* For a plain foundation-model id (e.g. `anthropic.claude-3-haiku-20240307-v1:0`) this is the
222+
* single regional foundation-model ARN, preserving the original behavior.
223+
*
224+
* For a cross-region inference profile id (prefixed with `us.`, `eu.`, or `apac.`) invoking the
225+
* profile requires `bedrock:InvokeModel` on BOTH the inference-profile ARN AND the underlying
226+
* regional foundation-model ARNs the profile routes to. The underlying model id is the profile id
227+
* with the region prefix stripped, and the foundation-model ARN is granted with a wildcard region
228+
* so it covers every region the profile may dispatch to. Without this, customers using a
229+
* cross-region profile with `@generation` hit runtime AccessDenied.
230+
*
231+
* @param {Construct} scope - The construct scope used to resolve account/partition tokens.
232+
* @param {string} region - The AWS region for the Bedrock service.
233+
* @param {string} bedrockModelId - The foundation-model id or cross-region inference profile id.
234+
* @returns {string[]} The resource ARNs to grant `bedrock:InvokeModel` on.
235+
*/
236+
private bedrockInvokeModelResources(scope: Construct, region: string, bedrockModelId: string): string[] {
237+
const { partition, account } = cdk.Stack.of(scope);
238+
const inferenceProfileMatch = bedrockModelId.match(/^(us|eu|apac)\.(.+)$/);
239+
240+
if (inferenceProfileMatch) {
241+
const foundationModelId = inferenceProfileMatch[2];
242+
return [
243+
`arn:${partition}:bedrock:${region}:${account}:inference-profile/${bedrockModelId}`,
244+
`arn:${partition}:bedrock:*::foundation-model/${foundationModelId}`,
245+
];
246+
}
247+
248+
return [`arn:${partition}:bedrock:${region}::foundation-model/${bedrockModelId}`];
249+
}
250+
218251
private bedrockDataSourceName(fieldName: string): string {
219252
return `GenerationBedrockDataSource${toUpper(fieldName)}`;
220253
}

0 commit comments

Comments
 (0)