Bedrock の利用額に上限を設けて超過ユーザーを SCP で制限してみた

Bedrock の利用額に上限を設けて超過ユーザーを SCP で制限してみた

生成 AI の利用料が想定外に膨らまないか心配になったことはありませんか?今回は、Bedrock の利用額が上限を超えたユーザーを SCP で自動的に停止させる仕組みを試してみました。ユーザー単位での利用額把握から SCP による Bedrock の利用拒否まで、その実装方法を紹介します。
2026.11.09

はじめに

こんにちは、ほりぐちです。

みなさん、社内で生成 AI を使えるようにしたものの「使いすぎる人が出たらどうしよう」と心配になったことはありませんか?

例えば Claude Code を Amazon Bedrock 経由で使ってもらう場合、利用料は使った分だけかかります。部署ごとに月の予算が決まっていたり、検証用に配ったアカウントで想定外に使われたりしないために、一定の金額を超えたところで利用を止めたくなります。

そこで今回は、利用額が上限を超えたユーザーの Bedrock の利用を SCP で止める方法を試します。

Bedrock の利用額に上限を設けて、超えたら利用を止める仕組みを用意したい方はぜひご覧ください。

用語の解説

今回の記事で登場する用語について、先に解説をします。

  • CUR 2.0:AWS のコストと使用状況レポート(Cost and Usage Report)の新しい形式です。サービスごとの利用料を明細として出力でき、Amazon Athena などで SQL を使って集計できます
  • ARN:Amazon Resource Name の略で、IAM ユーザーやロールなど、AWS 上のものを一意に識別する名前です。ロールを引き受けたときの ARN は arn:aws:sts::アカウント ID:assumed-role/ロール名/セッション名 という形で、末尾にセッション名が入ります。今回の記事では、この末尾の部分をキーにクエリしてユーザーを識別します
  • sub:subject の略。IdP(Identity Provider)が発行する ID トークンに入っている、ユーザーごとに一意で変わらない識別子です。Amazon Cognito では UUID の形式になり、例えばa1b2c3d4-5678-90ab-cdef-1234567890ab のような文字列になります

前提:ユーザー単位の利用額を把握する

とあるユーザーの利用額が上限を超えたかどうかを判断するには、ユーザーごとの利用額が分かっている必要があります。この記事では、DevelopersIO 2026 Tokyo で登壇した際の内容をまとめたブログの方法で、利用額を把握している前提で進めます。

[登壇レポート] DevelopersIO 2026 Tokyoで 「Claude Code をローカルとサーバー上で利用した際の Bedrock 利用料をユーザー単位で一本化してみる 」というタイトルで登壇しました | DevelopersIO

登壇内容の抜粋とポイントの紹介

登壇資料の内容を抜粋し、ユーザー単位のコスト合算をする上でポイントとなる部分をまとめます。

2026 年 4 月に追加された「発信者 ID(IAM プリンシパル)割り当てデータを含める」を使います。この機能は「請求とコスト管理」のコンソールからデータエクスポートを作成するときに選択することができます。
SCR-20261028-mutj.png

この機能を有効にすると、CUR 2.0 の line_item_iam_principal 列に Bedrock API の呼び出し元の ARN が記録されます。これによって、同じアカウントでもユーザーごとの Bedrock 利用を識別することができるようになりました。
SCR-20261028-mswd.png

端末(ローカル PC・EC2)や AWS アカウントが違っても、末尾の識別子はユーザーが一緒なら同じになるので、末尾で集計するとユーザー単位の合計を計算できます。ちなみに、AWS アカウントが異なる場合アカウント ID が変わるので、アカウント ID とユーザー名などでフィルターすることでアカウントごと、ユーザーごとの利用料を調べる、といった使い方も可能です。
SCR-20261028-mtqa.png

複数 AWS アカウントを利用する場合でも、全部のアカウントで設定をする必要はありません。AWS Organizations の管理アカウントでエクスポートを作ると、メンバーアカウントの利用料もまとめて集計できます。
SCR-20261028-muhe.png

管理アカウントを使用している場合、CUR 2.0 テーブルには、管理アカウントと組織内のすべてのメンバーアカウントのコストと使用状況データが含まれます。

コストと使用状況レポート (CUR) 2.0 - AWS Data Exports

前提と注意

  • 今回は、検証環境として AWS Organizations 配下の 2 つのメンバーアカウントと管理アカウントを利用します
  • 検証では、IdP の代わりに Amazon Cognito のユーザープールを使い、ユーザーの sub をセッション名にして IAM ロールを引き受けています。呼び出し元の ARN の末尾が sub になるため、sub を基準にユーザーを識別できます
  • 同じ設定を行えば、 Microsoft Entra ID などほかの IdP でも同様にユーザー単位の集計と制御ができる見込みですが、Entra ID での検証はしていません

今回の構成

SCP(サービスコントロールポリシー)は、AWS Organizations の OU に付与し、権限の上限を決める機能です。

SCP には金額やトークン数を数えて能動的にサービスの利用を制限するような機能はないため、「制限を超えたか判断する役」を別に用意して、SCP は「超えたとして報告されたユーザーを止める役」として使います。全体の流れは次のとおりです。

  1. 利用者が IdP でサインインし、ID トークンを取得する
  2. トークンを STS に渡し、セッション名が sub の認証情報を取得する
  3. その認証情報で Bedrock を呼び出す
  4. 日次で Lambda が Athena を実行し、CUR 2.0 から当月のユーザー別の利用額を集計する
  5. 上限を超えたユーザーの sub を、SCP の Bedrock 呼び出しを拒否するリストに追加する
  6. 拒否リストに入ったユーザーは、Bedrock の呼び出しが拒否される

利用者側の IdP・STS・Bedrock はメンバーアカウント側に、集計と停止の処理(Athena・Lambda・SCP の更新)は管理アカウント側に置きます。

SCR-20261028-ncgj.png
※ Amazon EventBridge などを利用することで日次で自動実行をすることも可能ですが、今回の検証では手動で金額の集計と SCP の更新を実行しています。

1. セッション名を sub にしてロールを引き受ける

まずは、利用額をユーザー単位で集計し、SCP でも指定できるように、セッション名をユーザーの識別子にそろえます。

セッション名は、ロールを引き受けるときに付ける名前です。ARN の末尾に入るため、ここにユーザーの識別子を入れておくと、CUR 2.0 の集計でも SCP の条件でも同じ値でユーザーを特定できます。

それでは実際にやっていきましょう。IdP が発行する ID トークンには、次のように sub が入っています。

"sub": "a1b2c3d4-5678-90ab-cdef-1234567890ab"

利用者は、この sub をセッション名にして、STS でロールを引き受けます。

取得したIDトークンを使って、次のコマンドでロールを引き受けます。--role-session-name には、ID トークンに含まれる sub と同じ値を指定します。

aws sts assume-role-with-web-identity \
  --role-arn arn:aws:iam::111122223333:role/ClaudeCodeBedrockUser \
  --role-session-name a1b2c3d4-5678-90ab-cdef-1234567890ab \
  --web-identity-token "$ID_TOKEN"

ID_TOKEN には取得済みの ID トークンを格納しておきます。コマンドが成功すると、Bedrock の呼び出しに使用する一時認証情報が返ります。

さらに、ロールの信頼ポリシーで、セッション名とトークンの sub が一致したときだけ引き受けを許可します。信頼ポリシーは、そのロールを誰が引き受けられるかを決めるポリシーです。次の条件を入れます。

"Condition": {
  "StringEquals": {
    "sts:RoleSessionName": "${<issuer>:sub}"
  }
}

この条件を入れておかないと利用者が好きなセッション名でロールを引き受けることができてしまいます。その結果、例えば拒否リストに入っても別のセッション名を名乗ることで SCP を回避して Bedrock を利用したり、他人のふりをして Bedrock を利用することで、利用料を他ユーザーにツケることができてしまいます。

2. ユーザーごとの利用額を集計する

次に、CUR 2.0 から当月のユーザー別の利用額を取り出し、上限を超えた人を抽出します。

Lambda が Amazon Athena で次の SQL を実行します。ARN の末尾(sub)を取り出してユーザーごとに合計し、月の上限を超えた人だけを返します。

SELECT
  regexp_extract(line_item_iam_principal, '[^/]+$') AS sub,
  sum(line_item_unblended_cost)                     AS cost_usd
FROM cur2_table
WHERE billing_period = date_format(current_date, '%Y-%m')
  AND line_item_line_item_type = 'Usage'
  AND line_item_usage_type LIKE '%TokenCount%'
  AND regexp_like(line_item_iam_principal,
        ':assumed-role/BedrockUserRole/[0-9a-f-]{36}$')
GROUP BY 1
HAVING sum(line_item_unblended_cost) > 100   -- 月の上限(USD)

こちらのSQLは、月額100 USDを超えたユーザーを抽出する例です。後述の Lambda では上限を0.002 USDに設定しています。

3. 上限を超えたユーザーを SCP の拒否リストに追加する

最後に、抽出したユーザーを SCP の拒否リストに追加し、Bedrock の呼び出しを止めます。

SCP は次のようにします。Bedrock の呼び出しに関するアクションを Deny し、条件に aws:userid を使います。

{
  "Effect": "Deny",
  "Action": [
    "bedrock:InvokeModel",
    "bedrock:InvokeModelWithResponseStream",
    "bedrock:Converse",
    "bedrock:ConverseStream"
  ],
  "Resource": "*",
  "Condition": {
    "StringLike": {
      "aws:userid": ["*:a1b2c3d4-5678-90ab-cdef-1234567890ab"]
    }
  }
}

aws:userid は、ロールを引き受けたセッションでは AROAxxxxxxxx:セッション名 という形になります。AROA...(ロール ID)の部分をアスタリスクにして、セッション名(sub)だけでマッチさせています。こうすると、ロールやアカウントが違っても、同じユーザーを 1 つの条件で止められます。

拒否リストの更新は、Lambda が管理アカウントで AWS Organizations の UpdatePolicy を呼び、aws:userid の値の一覧を書き換える形で行います。上限を超えた人を追加し、超えていない人は外します。月が替わった場合や、上限を引き上げた場合は、拒否リストから外れます。

SCP の制限について

SCP にはいくつかの上限があるので注意が必要です。

  • SCP のサイズは 10,240 文字までです。今回のやり方では、拒否リストに入れられる文字数が制限できる人数の上限になります
  • アカウントにアタッチできる SCP は 10 件が上限です

4. 動作を確認する

Bedrock を 2 人のユーザーで利用し、その後上限を 2 ユーザーの利用額の間に置き、超えたほうだけが拒否されることを確認します。

それでは確認していきましょう。手順は次のとおりです。

  1. 2 人のユーザーで Claude Code を使い、利用額に差をつける
  2. CUR 2.0 に反映されるまで待つ
  3. 上限を 2 人の利用額の間に設定して、Lambda を実行する
  4. 超えたユーザーだけが拒否リストに入り、もう一方は引き続き Bedrock を使えることを確認する

今回の検証で、二人のユーザーが実際に Bedrock を利用した金額は次のとおりです。

ユーザー 利用額(USD)
ユーザー A 0.003749
ユーザー B 0.000774

そこで、今回は 0.002 ドルをコストの上限に設定しました。

SCR-20261028-orde.png

CUR 2.0 を Athena で当月分を利用者別に集計し、しきい値を超えた利用者を SCP の拒否リストに反映します。

今回利用した環境変数は次のとおりです。

環境変数 説明 既定値・設定例
POLICY_ID 更新するSCPのID 必須。例:p-xxxxxxxx
ATHENA_DB Athenaのデータベース名 既定:cur2
ATHENA_TABLE 集計対象のテーブル名 既定:cur2.test_export_bedrock
ATHENA_OUTPUT Athenaの結果保存先 必須。例:s3://example-bucket/athena-results/
ROLE_NAME セッション名を sub に固定したロール名 既定:ClaudeCodeBedrockUser
THRESHOLDS 料金しきい値(USD) 既定:{"default_usd": 0.002}

実際に利用した Lambda 関数は次の通りです。

import json
import os
import time
import boto3

athena = boto3.client("athena")
org = boto3.client("organizations")

POLICY_ID = os.environ["POLICY_ID"]
DB = os.environ.get("ATHENA_DB", "cur2")
TABLE = os.environ.get("ATHENA_TABLE", "cur2.test_export_bedrock")
ROLE = os.environ.get("ROLE_NAME", "ClaudeCodeBedrockUser")
OUTPUT = os.environ["ATHENA_OUTPUT"]

PLACEHOLDER = "__placeholder__"
SCP_MAX = 10240

ACTIONS = [
    "bedrock:InvokeModel",
    "bedrock:InvokeModelWithResponseStream",
    "bedrock:Converse",
    "bedrock:ConverseStream"
]

QUERY = """
SELECT
    element_at(split(line_item_iam_principal, '/'), -1) AS user_key,
    round(sum(line_item_unblended_cost), 6) AS cost_usd
FROM {table}
WHERE billing_period = date_format(current_date, '%Y-%m')
  AND line_item_line_item_type = 'Usage'
  AND line_item_usage_type LIKE '%TokenCount%'
  AND line_item_iam_principal LIKE 'arn:aws:sts::%:assumed-role/{role}/%'
  AND regexp_like(
      element_at(split(line_item_iam_principal, '/'), -1),
      '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
  )
GROUP BY 1
"""

def run_query():
    """Athenaで集計し、全ページの結果を取得する。"""
    qid = athena.start_query_execution(
        QueryString=QUERY.format(
            table=TABLE,
            role=ROLE,
        ),
        QueryExecutionContext={
            "Database": DB,
        },
        ResultConfiguration={
            "OutputLocation": OUTPUT,
        },
    )["QueryExecutionId"]

    for _ in range(60):
        status = athena.get_query_execution(
            QueryExecutionId=qid,
        )["QueryExecution"]["Status"]

        if status["State"] in ("SUCCEEDED", "FAILED", "CANCELLED"):
            break

        time.sleep(2)

    if status["State"] != "SUCCEEDED":
        raise RuntimeError(
            f"Athena {status['State']}: "
            f"{status.get('StateChangeReason')}"
        )

    costs = {}
    next_token = None
    first_page = True

    while True:
        params = {
            "QueryExecutionId": qid,
            "MaxResults": 1000,
        }

        if next_token:
            params["NextToken"] = next_token

        response = athena.get_query_results(**params)
        rows = response["ResultSet"]["Rows"]

        # 列名行があるのは最初のページだけ。
        if first_page:
            rows = rows[1:]
            first_page = False

        for row in rows:
            cells = [
                cell.get("VarCharValue")
                for cell in row["Data"]
            ]

            if cells[0]:
                costs[cells[0]] = float(cells[1] or 0)

        next_token = response.get("NextToken")

        if not next_token:
            break

    return costs, qid

def scp_content(subs):
    """拒否対象のsub一覧からSCP全体のJSONを生成する。"""
    return json.dumps(
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "DenyBedrockForOverQuotaUsers",
                    "Effect": "Deny",
                    "Action": ACTIONS,
                    "Resource": "*",
                    "Condition": {
                        "StringLike": {
                            "aws:userid": [
                                f"*:{sub}"
                                for sub in subs
                            ],
                        },
                    },
                },
            ],
        },
        separators=(",", ":"),
    )

def current_deny_list():
    """現在のSCPから、"*:<sub>"形式の拒否対象を取り出す。"""
    response = org.describe_policy(
        PolicyId=POLICY_ID,
    )
    document = json.loads(
        response["Policy"]["Content"]
    )

    for statement in document.get("Statement", []):
        value = (
            statement.get("Condition", {})
            .get("StringLike", {})
            .get("aws:userid")
        )

        if value:
            values = value if isinstance(value, list) else [value]

            return [
                item.split(":", 1)[1]
                for item in values
                if ":" in item
            ]

    return []

def lambda_handler(event, context):
    thresholds = json.loads(
        os.environ.get(
            "THRESHOLDS",
            '{"default_usd": 0.002}',
        )
    )

    default_threshold = float(
        thresholds.get("default_usd", 0.002)
    )

    costs, qid = run_query()

    # しきい値を超えたユーザーを拒否対象にする。
    over_quota = sorted(
        user_key
        for user_key, cost in costs.items()
        if cost > default_threshold
    )

    before = sorted(
        user_key
        for user_key in current_deny_list()
        if user_key != PLACEHOLDER
    )

    added = [
        user_key
        for user_key in over_quota
        if user_key not in before
    ]
    removed = [
        user_key
        for user_key in before
        if user_key not in over_quota
    ]

    # 拒否対象がいない場合は、空リストの代わりにダミー値を設定する。
    desired = over_quota or [PLACEHOLDER]
    content = scp_content(desired)

    result = {
        "query_execution_id": qid,
        "users_seen": len(costs),
        "costs": costs,
        "threshold_default_usd": default_threshold,
        "over_quota": over_quota,
        "deny_list_before": before,
        "added": added,
        "removed": removed,
        "scp_bytes": len(content),
        "updated": False,
    }

    if len(content) > SCP_MAX:
        result["error"] = (
            f"SCPが上限を超える"
            f"({len(content)} > {SCP_MAX})。更新しない"
        )
        print(json.dumps(result, ensure_ascii=False))
        return result

    if not added and not removed:
        result["note"] = "変更なし"
    else:
        # 今回は、指定したSCPの本文を丸ごと更新する。
        org.update_policy(
            PolicyId=POLICY_ID,
            Content=content,
        )
        result["updated"] = True

    print(json.dumps(result, ensure_ascii=False))
    return result

Lambda を実行すると、SCP の拒否リストにユーザー A の sub だけが追加されました。
実行後の SCP は次のとおりです。( userid はサンプルの値)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyBedrockForOverQuotaUsers",
      "Effect": "Deny",
      "Action": [
        "bedrock:InvokeModel",
        "bedrock:InvokeModelWithResponseStream",
        "bedrock:Converse",
        "bedrock:ConverseStream"
      ],
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:userid": [
            "*:a1b2c3d4-5678-90ab-cdef-1234567890ab"
          ]
        }
      }
    }
  ]
}

これで SCP にユーザー A の Bedrock 利用が拒否されるように登録されたはずなので、実際にユーザー A で Bedrock を利用してみましょう。

An error occurred (AccessDeniedException) when calling the Converse operation:
User: arn:aws:sts::111122223333:assumed-role/BedrockUserRole/a1b2c3d4-5678-90ab-cdef-1234567890ab
is not authorized to perform: bedrock:InvokeModel ...
with an explicit deny in a service control policy

制限を超えたユーザー A の呼び出しは AccessDeniedException で拒否されました。エラーメッセージには、SCP による明示的な拒否であることが示されます。

補足:この方法の限界

  • 止まる時期:CUR 2.0 は日次で更新されるため、止まるのは上限を超えた翌日以降になります。その間の利用は止まりません
  • 拒否の表示:Claude Code は上限超過を認識するわけではなく、SCP の拒否エラーが返るだけです。上限超過を伝える案内は別に必要です。たとえば、Slack やメールで通知する、Claude Code の hook で拒否リストを確認して、対象に追加されていたらその旨を通知する、といった方法が考えられます(未検証)
  • 人数の上限:SCP は 10,240 文字までのため、sub を 1 件ずつ並べる形だと、目安として 200 名程度が上限になります(sub を 36 文字として計算)
  • リアルタイムに止めたい場合は、モデル呼び出しログのトークン数から概算して判断する仕組みを作る形になります

まとめ

おつかれさまでした!

今回は、Bedrock の利用額が上限を超えたユーザーに対して、SCP で利用を止める仕組みを試しました。

  • セッション名を sub にそろえて、ユーザーを一意に識別できるようにしました
  • CUR 2.0 をユーザー別に集計し、上限を超えた人を抽出しました
  • 抽出したユーザーを SCP の拒否リストに追加し、Bedrock の呼び出しを止めました

ポイントは、aws:userid のセッション名の部分で、ユーザーを指定できることです。ただし、ユーザーが自由にセッション名を決められないように、信頼ポリシーでセッション名を固定しておく必要があります。また、請求データをもとにするため、止まるのは超過の翌日以降になり、拒否リストに入れられる人数にも上限があります。

この記事が、Bedrock の利用額に上限を設ける際の参考になれば幸いです。

参考


そのマルチアカウント運用、気合いで支えていませんか

Organizations や Control Tower で土台は作れても、アカウントもポリシーも増えるほど、運用は「詳しい一人」に寄りかかっていく。属人化が限界を迎える前に、組織として回す仕組み=CCoEへ。5,600社の支援から得た立ち上げの型を、無料資料にまとめました。

CCoE総合支援

組織で回す仕組みの資料をもらう

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事