AWS Bedrock’s per-token pricing model creates a class of cost risk that traditional cloud governance tooling was not designed for. A single misconfigured agent loop, an unapproved Claude Opus invocation from a dev account, or a Provisioned Throughput commitment made without budget review can generate five- or six-figure surprises on a single monthly bill. This note documents production-grade, policy-as-code patterns across the full governance stack: SCPs, IAM permission boundaries, Terraform/OpenTofu modules, OPA/Rego plan validators, AWS Config rules, CI/CD cost gates, Sentinel policies, and automated Lambda remediation.
Service Control Policies sit above IAM — they set the maximum possible permissions for every principal in an AWS Organization account. No IAM policy, no matter how permissive, can grant access that an SCP denies. As of September 2025, SCPs support the complete IAM condition key language, enabling fine-grained Bedrock controls.
1.1 Deny Unapproved Foundation Models
This is the single highest-ROI SCP for Bedrock cost control. Without it, any developer can invoke Claude Opus 4 or Llama 3 405B in production, generating costs 10–50× higher than equivalent Haiku or Titan runs.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnapprovedBedrockModels",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream"
],
"Resource": "*",
"Condition": {
"StringNotLike": {
"bedrock:ModelId": [
"amazon.titan-text-lite-v1",
"amazon.titan-text-express-v1",
"amazon.titan-text-premier-v1:0",
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"meta.llama3-8b-instruct-v1:0",
"cohere.command-r-v1:0"
]
}
}
}
]
}
Operator notes:
- Maintain an approved-model list in a central SSM Parameter Store path (
/bedrock/governance/approved-models) and automate SCP regeneration from it via a scheduled Lambda. - Use a separate SCP with
ArnLikeon aPrincipalArncondition to carve out anml-researchOU where researchers can access the full model catalog under budget alert coverage. bedrock:ModelIdalso matches Application Inference Profile ARNs, so approved-profile enforcement follows from the same SCP.
1.2 Enforce Mandatory Tagging Before Invocation
AWS Bedrock Application Inference Profiles (AIPs) carry cost allocation tags into CUR 2.0. The SCP below denies direct model invocation and requires all callers to route through a tagged AIP — making unattributed spend structurally impossible.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireInferenceProfileForAllInvocations",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream"
],
"Resource": "arn:aws:bedrock:*::foundation-model/*",
"Condition": {
"Null": {
"bedrock:InferenceProfileArn": "true"
}
}
},
{
"Sid": "RequireCostCenterTagOnInferenceProfile",
"Effect": "Deny",
"Action": "bedrock:CreateInferenceProfile",
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/CostCenter": "true"
}
}
}
]
}
1.3 Restrict Cross-Region Inference to Approved Regions
Cross-region routing routes inference traffic through multiple AWS regions. This increases cost and may violate data-residency requirements. The SCP below permits only explicit approved regions; it exempts the bedrock:Invoke* actions from any broader region-deny SCP that Control Tower may have already applied.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyBedrockInUnapprovedRegions",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream",
"bedrock:CreateProvisionedModelThroughput",
"bedrock:CreateInferenceProfile"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"us-east-1",
"us-west-2",
"eu-west-1"
]
}
}
}
]
}
Note on Control Tower region-deny interaction: If your org uses the built-in CTMULTISERVICEPV1 region-deny guardrail, carve out bedrock:Invoke* at the OU level using Control Tower’s exemption mechanism, then enforce your own tighter regional SCP above — this avoids the cross-region inference breakage documented in aws-solutions/innovation-sandbox-on-aws issue #74.
1.4 Cap Provisioned Throughput Commitment Terms
Provisioned Throughput commitments start at ~$15,000/month for 1-month terms. Multi-month commitments are irrevocable. The SCP below prevents any principal from creating a PT resource with a commitment term longer than one month.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyMultiMonthProvisionedThroughput",
"Effect": "Deny",
"Action": "bedrock:CreateProvisionedModelThroughput",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"bedrock:CommitmentDuration": "OneMonth"
}
}
},
{
"Sid": "DenyProvisionedThroughputWithoutApprovalTag",
"Effect": "Deny",
"Action": "bedrock:CreateProvisionedModelThroughput",
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/FinOpsApproved": "true"
}
}
}
]
}
The FinOpsApproved tag acts as a soft approval token: the FinOps team applies it to PT resources via the Terraform module (section 3), and the SCP enforces that no PT resource can be created without it. This creates an approval gate without a human-in-the-loop delay: the tag is emitted by the approved Terraform module only after a Sentinel/OPA check passes in CI.
2. IAM Permission Boundaries for Cost Control
Permission boundaries constrain the maximum permissions available to an IAM role without modifying its trust policy or identity-based policies. They are the right tool for multi-team Bedrock platforms where a central platform team creates roles for product teams, but cannot trust those teams not to escalate their own permissions.
2.1 Model-Family Restriction Boundary
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowBedrockInvokeApprovedFamilies",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream"
],
"Resource": [
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3*haiku*",
"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text*",
"arn:aws:bedrock:us-east-1:*:application-inference-profile/*"
]
},
{
"Sid": "DenyProvisionedThroughputCreation",
"Effect": "Deny",
"Action": [
"bedrock:CreateProvisionedModelThroughput",
"bedrock:UpdateProvisionedModelThroughput",
"bedrock:DeleteProvisionedModelThroughput"
],
"Resource": "*"
},
{
"Sid": "DenyModelCustomization",
"Effect": "Deny",
"Action": [
"bedrock:CreateModelCustomizationJob",
"bedrock:CreateModelImportJob"
],
"Resource": "*"
},
{
"Sid": "AllowReadOperations",
"Effect": "Allow",
"Action": [
"bedrock:ListFoundationModels",
"bedrock:GetFoundationModel",
"bedrock:ListInferenceProfiles",
"bedrock:GetInferenceProfile"
],
"Resource": "*"
}
]
}
Attach this boundary to every role created by the platform team using a Lambda-backed Service Catalog product. A role that attaches a permissive identity-based policy (e.g., bedrock:*) still cannot invoke Claude Opus because the boundary denies it — the effective permission is the intersection.
2.2 Tagging Enforcement at Create-Time
{
"Sid": "EnforceTagsOnBedrockResources",
"Effect": "Deny",
"Action": [
"bedrock:CreateAgent",
"bedrock:CreateKnowledgeBase",
"bedrock:CreateInferenceProfile",
"bedrock:CreateProvisionedModelThroughput",
"bedrock:CreateFlow"
],
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/Team": "true",
"aws:RequestTag/CostCenter": "true",
"aws:RequestTag/Environment": "true"
}
}
}
These three tags (Team, CostCenter, Environment) are the minimum required for Cost Explorer segmentation. Add this statement to both your SCP and the permission boundary — belt and suspenders, since SCPs only cover organizational accounts, and permission boundaries additionally protect roles in accounts that have not yet been enrolled.
3. Terraform / OpenTofu Patterns
3.1 Bedrock Resource Module with Cost Guardrails
The community module aws-ia/terraform-aws-bedrock provides a starting foundation. The pattern below wraps it in a cost-aware module that enforces tagging, caps PT terms, and optionally creates an Application Inference Profile for every model invocation path.
# modules/bedrock-workload/variables.tf
variable "model_id" {
description = "Approved Bedrock foundation model ID. Must be in the approved-models allow-list."
type = string
validation {
condition = contains([
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"amazon.titan-text-lite-v1",
"amazon.titan-text-express-v1",
"amazon.titan-text-premier-v1:0",
"meta.llama3-8b-instruct-v1:0",
"cohere.command-r-v1:0"
], var.model_id)
error_message = "Model ID is not in the approved list. Update the approved list via the FinOps governance process."
}
}
variable "provisioned_throughput" {
description = "Optional Provisioned Throughput configuration. Only one-month commitments allowed."
type = object({
model_units = number
commitment_duration = string
})
default = null
validation {
condition = var.provisioned_throughput == null || var.provisioned_throughput.commitment_duration == "OneMonth"
error_message = "Provisioned Throughput commitment must be OneMonth. Multi-month commitments require FinOps board approval."
}
validation {
condition = var.provisioned_throughput == null || var.provisioned_throughput.model_units <= 10
error_message = "Model units above 10 require explicit FinOps approval (expected monthly cost >$45,000)."
}
}
variable "mandatory_tags" {
description = "Required cost allocation tags for all Bedrock resources."
type = object({
Team = string
CostCenter = string
Environment = string
Application = string
ManagedBy = string
})
}
# modules/bedrock-workload/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.50.0"
}
}
}
locals {
base_tags = merge(var.mandatory_tags, {
ManagedBy = "terraform"
BedrockModel = var.model_id
})
}
# Application Inference Profile — all invocations must route through this
resource "aws_bedrock_inference_profile" "workload" {
name = "${var.mandatory_tags.Application}-${var.mandatory_tags.Environment}"
description = "Cost-attributed inference profile for ${var.mandatory_tags.Application}"
model_source {
copy_from = "arn:aws:bedrock:${data.aws_region.current.name}::foundation-model/${var.model_id}"
}
tags = merge(local.base_tags, {
FinOpsApproved = "true"
})
}
# Optional Provisioned Throughput — only created when explicitly requested
resource "aws_bedrock_provisioned_model_throughput" "workload" {
count = var.provisioned_throughput != null ? 1 : 0
provisioned_model_name = "${var.mandatory_tags.Application}-pt"
model_id = var.model_id
model_units = var.provisioned_throughput.model_units
commitment_duration = var.provisioned_throughput.commitment_duration
tags = merge(local.base_tags, {
FinOpsApproved = "true"
PTModelUnits = tostring(var.provisioned_throughput.model_units)
PTCommitmentTerm = var.provisioned_throughput.commitment_duration
})
}
# Cost anomaly monitor — one per workload
resource "aws_ce_anomaly_monitor" "bedrock_workload" {
name = "bedrock-${var.mandatory_tags.Application}-anomaly"
monitor_type = "DIMENSIONAL"
monitor_dimension = "SERVICE"
}
resource "aws_ce_anomaly_subscription" "bedrock_workload" {
name = "bedrock-${var.mandatory_tags.Application}-alert"
frequency = "DAILY"
monitor_arn_list = [aws_ce_anomaly_monitor.bedrock_workload.arn]
subscriber {
type = "SNS"
address = var.anomaly_sns_topic_arn
}
threshold_expression {
and {
dimension {
key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE"
values = ["25"]
match_options = ["GREATER_THAN_OR_EQUAL"]
}
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
values = ["100"]
match_options = ["GREATER_THAN_OR_EQUAL"]
}
}
}
}
data "aws_region" "current" {}
output "inference_profile_arn" {
description = "ARN of the Application Inference Profile. Use this ARN in all SDK calls."
value = aws_bedrock_inference_profile.workload.id
}
3.2 AWS Service Quotas Request Automation
Service Quotas for Bedrock (tokens per minute, requests per minute) are account-specific and need to be provisioned before load tests or production launches. Automating quota requests in Terraform prevents last-minute escalations that bypass governance.
resource "aws_servicequotas_service_quota" "bedrock_tpm" {
service_code = "bedrock"
quota_code = "L-BE5442D5" # On-demand tokens per minute, Claude 3 Haiku
value = var.requested_tpm_quota
lifecycle {
# Quota reductions require AWS support — prevent accidental decreases
prevent_destroy = true
ignore_changes = [value]
}
}
4. OPA / Rego Policies for Bedrock Cost Governance
Open Policy Agent evaluates Terraform plans (exported as JSON via terraform show -json) before apply, rejecting configurations that violate cost or governance rules.
4.1 Policy: Deny Unapproved Models in Terraform Plans
# policies/bedrock_approved_models.rego
package bedrock.cost.approved_models
import rego.v1
approved_models := {
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"amazon.titan-text-lite-v1",
"amazon.titan-text-express-v1",
"amazon.titan-text-premier-v1:0",
"meta.llama3-8b-instruct-v1:0",
"cohere.command-r-v1:0",
}
# Collect all Bedrock resources that specify a model_id
bedrock_model_resources := [r |
r := input.planned_values.root_module.resources[_]
r.type in {
"aws_bedrock_provisioned_model_throughput",
"aws_bedrock_model_customization_job",
"aws_bedrock_inference_profile",
}
]
# Flag any resource using a non-approved model
violations[msg] {
r := bedrock_model_resources[_]
model := r.values.model_id
not model in approved_models
msg := sprintf(
"Resource %q uses unapproved model %q. Approved models: %v",
[r.address, model, approved_models]
)
}
deny[msg] {
msg := violations[_]
}
4.2 Policy: Enforce Mandatory Tags on All Bedrock Resources
# policies/bedrock_required_tags.rego
package bedrock.cost.tagging
import rego.v1
required_tags := {"Team", "CostCenter", "Environment", "Application"}
bedrock_taggable_resources := [r |
r := input.planned_values.root_module.resources[_]
startswith(r.type, "aws_bedrock_")
]
missing_tags(resource) := missing {
provided := {k | resource.values.tags[k]}
missing := required_tags - provided
}
violations[msg] {
r := bedrock_taggable_resources[_]
missing := missing_tags(r)
count(missing) > 0
msg := sprintf(
"Resource %q is missing required cost tags: %v",
[r.address, missing]
)
}
deny[msg] {
msg := violations[_]
}
4.3 Policy: Block Multi-Month Provisioned Throughput
# policies/bedrock_pt_term_limit.rego
package bedrock.cost.provisioned_throughput
import rego.v1
allowed_commitment_durations := {"OneMonth"}
pt_resources := [r |
r := input.planned_values.root_module.resources[_]
r.type == "aws_bedrock_provisioned_model_throughput"
]
violations[msg] {
r := pt_resources[_]
duration := r.values.commitment_duration
not duration in allowed_commitment_durations
msg := sprintf(
"Resource %q uses commitment duration %q. Only OneMonth is permitted without FinOps board approval.",
[r.address, duration]
)
}
violations[msg] {
r := pt_resources[_]
units := r.values.model_units
units > 10
msg := sprintf(
"Resource %q requests %d model units (est. >$45k/month). Requires FinOps board approval and manual apply.",
[r.address, units]
)
}
deny[msg] {
msg := violations[_]
}
4.4 Running OPA in CI
# In CI pipeline (GitHub Actions, GitLab CI, etc.)
# Step 1: Generate plan JSON
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Step 2: Evaluate all Bedrock cost policies
opa eval \
--data policies/ \
--input tfplan.json \
--format pretty \
'data.bedrock.cost.approved_models.deny |
data.bedrock.cost.tagging.deny |
data.bedrock.cost.provisioned_throughput.deny'
# Step 3: Fail the pipeline if any denies exist
opa eval \
--data policies/ \
--input tfplan.json \
--fail-defined \
'data[_][_].deny[_]'
5. AWS Config Rules for Bedrock Compliance
AWS Config provides continuous compliance monitoring — it detects drift after deployment, where OPA operates pre-deployment.
5.1 Custom Config Rule: Untagged Bedrock Inference Profiles
# config_rules/bedrock_inference_profile_tags.py
# Deploy as an AWS Lambda function backed by a custom Config rule.
import boto3
import json
REQUIRED_TAGS = {"Team", "CostCenter", "Environment", "Application"}
def lambda_handler(event, context):
invoking_event = json.loads(event["invokingEvent"])
configuration_item = invoking_event.get("configurationItem")
# Only evaluate aws_bedrock application inference profiles
if not configuration_item:
return put_evaluations(event, [], context)
resource_type = configuration_item.get("resourceType", "")
if resource_type != "AWS::Bedrock::ApplicationInferenceProfile":
return put_evaluations(event, [], context)
tags = configuration_item.get("tags", {})
resource_id = configuration_item["resourceId"]
resource_arn = configuration_item["ARN"]
missing = REQUIRED_TAGS - set(tags.keys())
if missing:
compliance = "NON_COMPLIANT"
annotation = f"Missing required cost tags: {sorted(missing)}"
else:
compliance = "COMPLIANT"
annotation = "All required cost tags present."
evaluation = {
"ComplianceResourceType": resource_type,
"ComplianceResourceId": resource_id,
"ComplianceType": compliance,
"Annotation": annotation,
"OrderingTimestamp": configuration_item["configurationItemCaptureTime"],
}
config_client = boto3.client("config")
config_client.put_evaluations(
Evaluations=[evaluation],
ResultToken=event["resultToken"]
)
5.2 Custom Config Rule: Idle Provisioned Throughput Endpoints
A Provisioned Throughput resource is “idle” if it has received zero invocations for 72+ hours while still incurring commitment charges. The Config rule below flags it; the auto-remediation Lambda in section 8 handles the alert.
# config_rules/bedrock_idle_provisioned_throughput.py
import boto3
import json
from datetime import datetime, timezone, timedelta
IDLE_THRESHOLD_HOURS = 72
METRIC_NAMESPACE = "AWS/Bedrock"
def get_invocation_count(pt_arn: str, hours: int) -> float:
cw = boto3.client("cloudwatch")
end = datetime.now(timezone.utc)
start = end - timedelta(hours=hours)
response = cw.get_metric_statistics(
Namespace=METRIC_NAMESPACE,
MetricName="InvocationLatency",
Dimensions=[{"Name": "ProvisionedModelArn", "Value": pt_arn}],
StartTime=start,
EndTime=end,
Period=hours * 3600,
Statistics=["SampleCount"],
)
datapoints = response.get("Datapoints", [])
if not datapoints:
return 0.0
return sum(d["SampleCount"] for d in datapoints)
def lambda_handler(event, context):
invoking_event = json.loads(event["invokingEvent"])
ci = invoking_event.get("configurationItem")
if not ci or ci.get("resourceType") != "AWS::Bedrock::ProvisionedModelThroughput":
return
pt_arn = ci["ARN"]
resource_id = ci["resourceId"]
invocations = get_invocation_count(pt_arn, IDLE_THRESHOLD_HOURS)
if invocations == 0:
compliance = "NON_COMPLIANT"
annotation = (
f"Provisioned Throughput endpoint has 0 invocations in the last "
f"{IDLE_THRESHOLD_HOURS}h. Consider releasing commitment to avoid waste."
)
else:
compliance = "COMPLIANT"
annotation = f"{invocations:.0f} invocations in last {IDLE_THRESHOLD_HOURS}h."
config_client = boto3.client("config")
config_client.put_evaluations(
Evaluations=[{
"ComplianceResourceType": ci["resourceType"],
"ComplianceResourceId": resource_id,
"ComplianceType": compliance,
"Annotation": annotation,
"OrderingTimestamp": ci["configurationItemCaptureTime"],
}],
ResultToken=event["resultToken"]
)
5.3 Managed Config Rules to Enable
These AWS-managed Config rules require no custom code:
| Rule | What It Detects |
|---|---|
required-tags |
Any Bedrock resource missing Team, CostCenter, Environment |
restricted-common-ports |
Not Bedrock-specific, but catches VPC misconfigs that inflate cross-region inference costs |
cloudtrail-enabled |
Ensures Bedrock API calls are auditable (required for cost anomaly investigation) |
Terraform resource:
resource "aws_config_config_rule" "bedrock_required_tags" {
name = "bedrock-required-tags"
source {
owner = "AWS"
source_identifier = "REQUIRED_TAGS"
}
input_parameters = jsonencode({
tag1Key = "Team"
tag2Key = "CostCenter"
tag3Key = "Environment"
tag4Key = "Application"
})
scope {
compliance_resource_types = [
"AWS::Bedrock::ApplicationInferenceProfile",
"AWS::Bedrock::ProvisionedModelThroughput",
"AWS::Bedrock::Agent",
"AWS::Bedrock::KnowledgeBase",
]
}
}
6. Cost Governance in CI/CD Pipelines
6.1 FinOps-as-Code Workflow
The canonical flow for Bedrock workload changes:
git push → terraform plan → OPA deny check → Infracost estimate
→ cost delta threshold check → PR comment with cost impact
→ Sentinel soft-mandatory check → human approval if Δcost > $500/mo
→ terraform apply
6.2 GitHub Actions: Infracost + OPA Gate
# .github/workflows/bedrock-cost-gate.yml
name: Bedrock Cost Gate
on:
pull_request:
paths:
- 'infra/bedrock/**'
- 'infra/modules/bedrock-workload/**'
jobs:
policy-and-cost:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.9"
- name: Terraform Init
run: terraform -chdir=infra/bedrock init -backend=false
- name: Terraform Plan
run: |
terraform -chdir=infra/bedrock plan \
-out=tfplan.binary \
-var-file=environments/prod.tfvars
terraform -chdir=infra/bedrock show -json tfplan.binary > tfplan.json
- name: OPA Policy Check
uses: open-policy-agent/setup-opa@v2
with:
version: latest
- name: Run Bedrock Cost Policies
run: |
opa eval \
--data policies/ \
--input tfplan.json \
--fail-defined \
'data[_][_].deny[_]' \
&& echo "All OPA policies passed." \
|| (echo "OPA policy violations found. See output above." && exit 1)
- name: Infracost Cost Estimate
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate Infracost Breakdown
run: |
infracost breakdown \
--path tfplan.json \
--format json \
--out-file infracost-base.json
- name: Post Cost Comment to PR
uses: infracost/actions/comment@v3
with:
path: infracost-base.json
behavior: update
- name: Enforce Cost Delta Threshold
run: |
MONTHLY_DELTA=$(jq '.diffTotalMonthlyCost // "0"' infracost-base.json -r)
THRESHOLD=500
python3 -c "
delta = float('${MONTHLY_DELTA}')
threshold = float('${THRESHOLD}')
if delta > threshold:
print(f'BLOCKED: Monthly cost increase \${delta:.2f} exceeds threshold \${threshold:.2f}')
exit(1)
else:
print(f'APPROVED: Monthly cost delta \${delta:.2f} within threshold.')
"
7. Sentinel Policies for Terraform Cloud / Enterprise
Sentinel runs inside Terraform Cloud’s run pipeline after plan but before apply. It is the right enforcement layer for teams using TFC/TFE — it provides a soft-mandatory mode that allows authorized overrides, which is better UX than a hard OPA block for expensive-but-legitimate workloads.
7.1 Sentinel Policy: Bedrock PT Term Limit
# sentinel/bedrock-pt-term-limit.sentinel
import "tfplan/v2" as tfplan
# Collect all planned Provisioned Throughput resources
pt_resources = filter tfplan.resource_changes as _, rc {
rc.type is "aws_bedrock_provisioned_model_throughput" and
rc.change.actions contains "create"
}
# Rule: Only OneMonth commitments
rule no_multi_month_pt {
all pt_resources as _, rc {
rc.change.after.commitment_duration is "OneMonth"
}
}
# Rule: Model units cap
rule model_units_cap {
all pt_resources as _, rc {
rc.change.after.model_units <= 10
}
}
# Soft-mandatory: finance team can override for legitimate large commitments
main = rule {
no_multi_month_pt and model_units_cap
}
7.2 Sentinel Policy: Required Tags on All Bedrock Resources
# sentinel/bedrock-required-tags.sentinel
import "tfplan/v2" as tfplan
required_tags = ["Team", "CostCenter", "Environment", "Application"]
bedrock_creates = filter tfplan.resource_changes as _, rc {
rc.type matches "^aws_bedrock_" and
rc.change.actions contains "create"
}
rule all_bedrock_resources_tagged {
all bedrock_creates as _, rc {
all required_tags as tag {
rc.change.after.tags[tag] is not null and
rc.change.after.tags[tag] is not ""
}
}
}
main = rule { all_bedrock_resources_tagged }
Register these policies in your TFC workspace’s policy set and set enforcement_level = "soft-mandatory" for the PT term limit (allows FinOps team override) and "hard-mandatory" for the tagging rule (no exceptions).
8. Automated Remediation: Lambda Functions
8.1 Cost Anomaly → SNS → Lambda Throttle Responder
When AWS Cost Anomaly Detection fires, an SNS message reaches this Lambda. It evaluates the anomaly against a runbook and either throttles the offending inference profile or notifies the team.
# lambdas/bedrock_anomaly_responder.py
import boto3
import json
import os
from datetime import datetime, timezone
bedrock = boto3.client("bedrock")
sns = boto3.client("sns")
ssm = boto3.client("ssm")
ALERT_SNS_ARN = os.environ["ALERT_SNS_ARN"]
AUTO_THROTTLE_THRESHOLD_USD = float(os.environ.get("AUTO_THROTTLE_THRESHOLD_USD", "200"))
def lambda_handler(event, context):
for record in event.get("Records", []):
message = json.loads(record["Sns"]["Message"])
anomaly = message.get("anomaly", {})
impact = float(anomaly.get("impact", {}).get("totalActualSpend", 0))
root_causes = anomaly.get("rootCauses", [])
actions_taken = []
for cause in root_causes:
if cause.get("service") != "Amazon Bedrock":
continue
# Log anomaly context to SSM for audit trail
ssm.put_parameter(
Name=f"/bedrock/anomalies/{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
Value=json.dumps({
"impact_usd": impact,
"root_cause": cause,
"timestamp": datetime.now(timezone.utc).isoformat(),
}),
Type="String",
Overwrite=False,
)
if impact >= AUTO_THROTTLE_THRESHOLD_USD:
# Find inference profiles with anomalous spend and suspend them
# This is a soft throttle: mark the profile as suspended via a tag
# The application checks this tag before routing requests
profiles = bedrock.list_inference_profiles(typeEquals="APPLICATION").get(
"inferenceProfileSummaries", []
)
for profile in profiles:
if cause.get("usageType", "") in profile.get("inferenceProfileId", ""):
bedrock.tag_resource(
resourceARN=profile["inferenceProfileArn"],
tags=[{
"key": "BedrockThrottleStatus",
"value": f"SUSPENDED_ANOMALY_{datetime.now(timezone.utc).strftime('%Y%m%d')}"
}]
)
actions_taken.append(f"Suspended profile {profile['inferenceProfileId']}")
# Always notify
sns.publish(
TopicArn=ALERT_SNS_ARN,
Subject=f"Bedrock Cost Anomaly: ${impact:.2f} detected",
Message=json.dumps({
"impact_usd": impact,
"root_causes": root_causes,
"actions_taken": actions_taken,
"threshold_usd": AUTO_THROTTLE_THRESHOLD_USD,
"timestamp": datetime.now(timezone.utc).isoformat(),
}, indent=2)
)
return {"statusCode": 200, "actions": actions_taken}
8.2 Terraform for the Anomaly Responder
# infrastructure for the anomaly responder Lambda
resource "aws_lambda_function" "bedrock_anomaly_responder" {
function_name = "bedrock-anomaly-responder"
handler = "bedrock_anomaly_responder.lambda_handler"
runtime = "python3.12"
role = aws_iam_role.anomaly_responder.arn
filename = data.archive_file.anomaly_responder.output_path
environment {
variables = {
ALERT_SNS_ARN = aws_sns_topic.bedrock_alerts.arn
AUTO_THROTTLE_THRESHOLD_USD = "200"
}
}
tags = var.mandatory_tags
}
resource "aws_sns_topic_subscription" "anomaly_to_lambda" {
topic_arn = aws_sns_topic.cost_anomaly_alerts.arn
protocol = "lambda"
endpoint = aws_lambda_function.bedrock_anomaly_responder.arn
}
resource "aws_lambda_permission" "sns_invoke" {
statement_id = "AllowSNSInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.bedrock_anomaly_responder.arn
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.cost_anomaly_alerts.arn
}
9. Tag Compliance Enforcement and Auto-Remediation
9.1 AWS Config + SSM Auto-Remediation for Untagged Resources
The Config rule from section 5.1 triggers an SSM Automation document when it detects a non-compliant resource. The document attempts to add missing tags by inferring them from the resource’s IAM creator identity (via CloudTrail lookup) and the account’s tag policy.
# SSM Automation Document: bedrock-tag-remediation
description: "Auto-remediate missing cost tags on Bedrock resources by inferring from CloudTrail and tag policies."
schemaVersion: "0.3"
assumeRole: "{{ AutomationAssumeRole }}"
parameters:
ResourceArn:
type: String
ResourceType:
type: String
mainSteps:
- name: LookupCreatorIdentity
action: aws:executeScript
inputs:
Runtime: python3.11
Handler: lookup_creator
Script: |
import boto3
from datetime import datetime, timezone, timedelta
def lookup_creator(events, context):
ct = boto3.client("cloudtrail")
arn = events["ResourceArn"]
# Search last 24h for the create event for this resource
events_resp = ct.lookup_events(
LookupAttributes=[{"AttributeKey": "ResourceName", "AttributeValue": arn}],
StartTime=datetime.now(timezone.utc) - timedelta(hours=24),
EndTime=datetime.now(timezone.utc),
)
for e in events_resp.get("Events", []):
if "Create" in e.get("EventName", ""):
return {"CreatorArn": e.get("Username", "unknown")}
return {"CreatorArn": "unknown"}
InputPayload:
ResourceArn: "{{ ResourceArn }}"
outputs:
- Name: CreatorArn
Selector: $.Payload.CreatorArn
- name: ApplyInferredTags
action: aws:executeAwsApi
inputs:
Service: bedrock
Api: TagResource
resourceARN: "{{ ResourceArn }}"
tags:
- key: "AutoRemediated"
value: "true"
- key: "RemediatedAt"
value: "{{ global:DATE_TIME }}"
- key: "CreatedBy"
value: "{{ LookupCreatorIdentity.CreatorArn }}"
Wire the remediation to the Config rule in Terraform:
resource "aws_config_remediation_configuration" "bedrock_tag_remediation" {
config_rule_name = aws_config_config_rule.bedrock_required_tags.name
target_type = "SSM_DOCUMENT"
target_id = "bedrock-tag-remediation"
automatic = true
maximum_automatic_attempts = 3
retry_attempt_seconds = 60
parameter {
name = "ResourceArn"
resource_value = "RESOURCE_ID"
}
parameter {
name = "AutomationAssumeRole"
static_value = aws_iam_role.config_remediation.arn
}
}
10. FinOps-as-Code Workflow: Full Pipeline
This is the complete decision flow from Terraform plan to apply gate, combining all layers above.
Developer opens PR with Bedrock infrastructure changes
│
├─ [PRE-PLAN STATIC] tflint + checkov scan for obvious misconfigs
│
├─ [PLAN] terraform plan → tfplan.json
│
├─ [POLICY GATE 1: OPA]
│ ├─ bedrock.cost.approved_models.deny → hard block on unapproved models
│ ├─ bedrock.cost.tagging.deny → hard block on missing tags
│ └─ bedrock.cost.provisioned_throughput.deny → hard block on >1-month PT
│
├─ [COST ESTIMATION] Infracost breakdown
│ ├─ Post cost delta to PR as comment
│ └─ If monthly delta > $500: require manual approval from FinOps team
│
├─ [POLICY GATE 2: SENTINEL] (TFC/TFE only)
│ ├─ bedrock-pt-term-limit.sentinel → soft-mandatory (FinOps can override)
│ └─ bedrock-required-tags.sentinel → hard-mandatory (no exceptions)
│
├─ [APPROVAL] If Infracost delta > $500 OR PT resource is new:
│ └─ GitHub required reviewer: @finops-team
│
└─ [APPLY] terraform apply (only after all gates pass)
│
└─ [POST-APPLY] AWS Config continuous compliance monitoring
├─ Checks: required tags, idle PT, unattributed invocations
└─ Violations → SSM auto-remediation or SNS alert → Lambda responder
11. Policy Library: 10 Ready-to-Use Policies
Policy 1 — SCP: Deny Unapproved Model Families
File: scps/bedrock-approved-models.json
Denies bedrock:InvokeModel* and bedrock:Converse* for any model not in an explicit allowlist. Apply at the non-prod OU and production OU separately with different allowed-model lists.
Policy 2 — SCP: Require Tagged Inference Profile
File: scps/bedrock-require-inference-profile.json
Denies direct foundation model ARN invocations; requires all calls to route through an Application Inference Profile ARN (which carries cost allocation tags). This is the strongest single policy for cost attribution.
Policy 3 — SCP: Restrict Cross-Region Inference to Approved Regions
File: scps/bedrock-region-restrict.json
Denies all Bedrock actions outside of us-east-1, us-west-2, eu-west-1. Includes bedrock:CreateProvisionedModelThroughput and bedrock:CreateInferenceProfile to prevent cost leakage to uncovered regions.
Policy 4 — SCP: Cap Provisioned Throughput Commitment
File: scps/bedrock-pt-term-cap.json
Denies bedrock:CreateProvisionedModelThroughput unless bedrock:CommitmentDuration == OneMonth and aws:RequestTag/FinOpsApproved is present. The FinOpsApproved tag is only emitted by the approved Terraform module, creating a soft approval chain.
Policy 5 — IAM Permission Boundary: Developer Role
File: iam-boundaries/bedrock-developer-boundary.json
Allows invocation of Haiku, Titan, and Llama 8B only. Denies PT creation, model customization, and model import. Attach to all roles created by the platform team for product developers.
Policy 6 — OPA/Rego: Approved Models Plan Check
File: policies/bedrock_approved_models.rego
Hard deny in CI if any planned Bedrock resource references a model ID outside the approved set. Blocks Claude Sonnet, Claude Opus, Llama 405B, and all frontier models from appearing in Terraform plans.
Policy 7 — OPA/Rego: Required Tags Plan Check
File: policies/bedrock_required_tags.rego
Hard deny in CI if any aws_bedrock_* resource is missing Team, CostCenter, Environment, or Application tags. Runs in the same OPA evaluation as policy 6.
Policy 8 — AWS Config: Untagged Inference Profile Detection
File: config_rules/bedrock_inference_profile_tags.py
Lambda-backed custom Config rule that evaluates AWS::Bedrock::ApplicationInferenceProfile resources for required tags. Non-compliant resources trigger SSM auto-remediation to apply inferred tags from CloudTrail.
Policy 9 — AWS Config: Idle Provisioned Throughput Detection
File: config_rules/bedrock_idle_provisioned_throughput.py
Lambda-backed custom Config rule that checks CloudWatch InvocationLatency SampleCount for PT endpoints. Resources with zero invocations in 72+ hours are flagged as NON_COMPLIANT, triggering SNS notification to the owning team.
Policy 10 — Lambda: Cost Anomaly Auto-Responder
File: lambdas/bedrock_anomaly_responder.py
Receives AWS Cost Anomaly Detection alerts via SNS. For anomalies above the auto-throttle threshold ($200 default), tags the offending Application Inference Profile as SUSPENDED_ANOMALY_<date>. Applications that check this tag stop routing to the profile. Below the threshold, sends a notification only. All events are logged to SSM Parameter Store for audit.
Key Operational Notes
Approved-model list drift: The SCP’s allowlist and the OPA policy’s approved_models set must stay in sync. Use a single source of truth in SSM Parameter Store (/bedrock/governance/approved-models) and generate both from it via a weekly automation. A CI test that diffs the SCP against the OPA policy catches divergence.
Cross-region inference vs. region-deny SCPs: The aws:RequestedRegion condition key behaves differently for cross-region inference (which uses routing prefixes, not explicit region values). Follow the Control Tower guidance to exempt bedrock:Invoke* from OU-level region-deny SCPs and enforce your own regional restriction separately, as documented in aws-solutions/innovation-sandbox-on-aws issue #74.
Provisioned Throughput tag chain: The FinOpsApproved tag used in SCP policy 4 must be applied by the Terraform module, not manually. This creates a traceable chain: PT resource exists → it was created by the Terraform module → the module only runs after OPA and Sentinel checks pass. A PT resource without the tag is structurally impossible if the SCP is attached at org root.
CUR 2.0 and Application Inference Profiles: Cost allocation tags on AIPs appear in CUR 2.0 (not CUR 1.0) under the column resource_tags/user_<TagKey>. The Infracost estimate in CI does not capture per-token costs for on-demand Bedrock (Infracost prices PT commitments but uses on-demand list rates for token costs). Supplement Infracost with a custom bedrock-cost-estimator step that multiplies planned prompt/completion token counts by model rates from the Bedrock pricing API.
FinOps agent integration: AWS launched a native FinOps Agent (public preview, 2025) that investigates Cost Anomaly Detection findings using CloudTrail to identify the IAM principal responsible. Route its output to the same Slack channel as the Lambda anomaly responder for consolidated alerting.
Sources
- Example SCPs for Amazon Bedrock — AWS Organizations docs
- Enforce Bedrock Usage Through Tagged Application Inference Profiles Using SCPs — AWS re:Post
- Implementing Least Privilege Access for Amazon Bedrock — AWS Security Blog
- Introducing Granular Cost Attribution for Amazon Bedrock — AWS ML Blog
- Application Inference Profiles — Amazon Bedrock docs
- Track Amazon Bedrock Costs by Caller Identity — AWS Cloud Financial Management Blog
- AWS Bedrock Permissions Boundaries: A FinOps Security Guide — Binadox
- Cross-Region Inference with Amazon Bedrock — AWS Builder Center
- Innovation Sandbox SCP cross-region inference issue #74 — GitHub
- aws-ia/terraform-aws-bedrock — GitHub
- OPA & Terraform: The Definitive Guide to Policy-as-Code Guardrails — policyascode.dev
- How to Implement Cost Policies with Sentinel for Terraform — oneuptime.com
- Infracost — GitHub
- AWS launches FinOps agent, expands Bedrock cost tracking — TechTarget
- From Spend Blindness to Cost Accountability — AWS Builder Center
- Essential AWS Service Control Policies for Enterprise Governance — Medium
- Automate AWS Cost Anomaly Detection with Terraform — Hykell
- Amazon Bedrock Pricing — AWS