← Agent Frameworks 🕐 9 min read
Agent Frameworks

Bedrock Cost Governance Policy-as-Code (2026)

AWS Bedrock's per-token pricing model creates a class of cost risk that traditional cloud governance tooling was not designed for.

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 ArnLike on a PrincipalArn condition to carve out an ml-research OU where researchers can access the full model catalog under budget alert coverage.
  • bedrock:ModelId also 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