Bedrock model access is three gates, not one permission

4 min#aws#architecture#devex

Before an AWS account can call a Bedrock foundation model, three independent things have to be true at once. The account needs an active AWS Marketplace subscription to that model. The account needs to have accepted the model's end-user license agreement, which AWS calls a model agreement. And the calling role's IAM policy needs to actually grant the invoke action for that specific model. Miss any one of the three and the API gives you back the exact same error: an AccessDeniedException with a message that does not say which of the three you're missing. I maintain a shared AWS platform for AI workloads at KnowBe4, and the design problem worth writing about isn't the error. It's that we treated "can this account use this model" as three separate, machine-checkable preconditions from the start, instead of leaving an engineer to guess from one opaque string.

Why one error string isn't enough information

A subscription gate, a license-acceptance gate, and an IAM gate fail identically on purpose, from AWS's side: AccessDeniedException is a generic access-control response, not a diagnostic. That's a reasonable design for a vendor API surface. It's a bad one to inherit as your own team's first line of defense, because the three fixes are completely different in who can apply them and how fast. IAM is something we control entirely in Terraform. A model agreement is something we can automate through the Bedrock API directly. A Marketplace subscription requires the AWS Marketplace console and admin-level account access. The Lambda's IAM role even carries aws-marketplace:Subscribe, added deliberately for a later automation attempt, but nothing in the Lambda's own code calls it, so subscribing today still means a human in the Marketplace console. If your only diagnostic signal is the error message, every one of these looks like the same ticket, and the person triaging it has no way to know whether they're about to fix it in five minutes or wait on an admin.

A nightly job that treats entitlement as its own concern

The core piece is a Lambda that runs nightly at 02:00 UTC on a flexible thirty-minute schedule. It enumerates Bedrock foundation models for the account and region, and for any model from a configured provider list (Anthropic, Amazon, Meta, Mistral, Cohere by default) that isn't yet entitled, it calls CreateFoundationModelAgreement to accept the model agreement automatically. Then it sends a one-token Converse call to warm the model and confirm it actually responds, not just that the agreement call returned success. This closes gate two, licensing, without a human in the loop, every night, across every account this Lambda covers. It does nothing for gate one, the Marketplace subscription: the Lambda's role has the IAM permission to touch that gate, but nothing in its code exercises it, so gate one still waits on a human in the console. And it does nothing for gate three, IAM, because that's a property of the calling role, not the model.

The job also runs today against a single region, and the runbook says so explicitly rather than leaving it implied: AccessDenied is per-account and per-region, and this Lambda only covers that one region so far. Stating the boundary in the runbook instead of letting someone discover it by testing a second region is the same instinct as splitting the three gates apart: make the limits of the system something you can read, not something you find by hitting them.

The gap between nightly and "a new model just shipped"

A nightly cadence is too slow the day a new model ships and a downstream team wants to use it immediately. For that we added an ad-hoc trigger, three manual CI jobs, one per environment, each allowed to fail without blocking a pipeline since they're not part of the deploy path. The production job only appears on tagged release pipelines, which has a consequence worth naming: a commit classified as chore, ci, or docs cuts no version tag, so it never produces a pipeline where that manual job exists to click. If someone needs entitlement fixed in production before the next real release, the CI trigger simply isn't reachable yet, and the runbook gives a direct aws lambda invoke command as the fallback for exactly that gap instead of leaving it as a dead end.

IAM scoped to match each gate, not one blanket policy

The Lambda's own IAM reflects the same three-gate thinking. Read-only discovery calls like ListFoundationModels are scoped broadly, because listing models carries no risk. But CreateFoundationModelAgreement is scoped tightly to this account's own foundation models, and invocation actions are scoped further still, to both the account's foundation models and the inference-profile/us.* namespace that cross-region inference profiles live under. Each policy statement maps to one gate. That mapping is what makes the runbook's diagnosis step meaningful instead of decorative: if the nightly job reports a model as already authorized and a caller still gets denied, the runbook says plainly that the caller's IAM policy is the problem, and that per-service IAM is outside what this particular runbook fixes. That's a useful boundary to draw on paper. It tells the next engineer exactly which of the three systems to go check first, instead of re-running the same failed API call and hoping the error message says something new.

More in Infrastructure

All in Infrastructure