SCP本番1年半で発見した、IAMとの組み合わせで権限が機能しない7つのケース

AWSのSCP運用で「設定したはずなのに機能していない」という落とし穴に気づいた話。IAMとの相互作用で陥りやすいデニーリストの失敗パターンを実体験から解説します。

うちのチームがSCPで権限地獄に陥った話

先日、チームの権限周りを棚卸ししていてぞっとした。SCPを導入して1年半運用してるのに、実は機能してないポリシーが7個も存在していたんだ。最初は「セキュリティを強化するぞ」って張り切ってたんだけど、IAMポリシーとの組み合わせで想像と違う挙動をしてて、ずっと気づかなかったんだよ。

SCPについて正直に書きたい。教科書的な「ガードレール」という説明は半分正しいけど、実装の落とし穴は本当に地味で重い。特にIAMとの相互作用は、公式ドキュメント読むだけだと絶対分からない部分がある。うちのチームが1年半かけて学んだこと、シェアしておく。

SCPとIAMの「本当の関係」—デニーリストとアローリストの共存地獄

最初、僕たちはSCPをシンプルに考えてた。「IAMポリシーの上位層」「全体的なガードレール」みたいな感じ。でも実際に本番環境で運用したら、思った以上に複雑だった。

特に痛感したのは、デニーリスト型のSCPとアローリスト型IAMポリシーの組み合わせだ。例えば、うちが書いたSCPがこれなんだけど。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": [
        "ec2:TerminateInstances",
        "ec2:DeleteSnapshot",
        "s3:DeleteBucket"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalOrgID": "o-xxxxxxxxxx"
        }
      }
    }
  ]
}

シンプルだ。「危険な操作は許可しない」という単純な考え方。でも問題は、IAM側でアローリストを使ってるときに起きる。IAMで「ec2:*は許可する」って書いてあると、SCPの効き方が予測不可能になるんだよ。

正確には、IAMで許可されてるのに、SCPでDenyされてれば、その操作は失敗する。これ自体は理に適ってるんだけど、問題は権限周りのトラブルシューティングのときに「どっちで止められたのか」が見分けにくいこと。CloudTrailを見ても、SCP由来のDenyとIAM由来のAccessDeniedが同じに見える。

UserInitiatedEvent: ec2:TerminateInstances
ErrorCode: AccessDenied
ErrorMessage: User: arn:aws:iam::123456789:user/dev is not authorized to perform: ec2:TerminateInstances on resource: arn:aws:ec2:...

このCloudTrailログだけ見たら、IAMで拒否されたのか、SCPで拒否されたのか、本気で分からない。調査に2時間費やしたよ。

データドリブンなSCP設計—本当に必要な規制と過度な規制の線引き

1年半運用してみて気づいたことは、SCPは「禁止」じゃなく「許可される範囲の定義」と考えるべきってことだ。デニーリストとアローリストの考え方を整理した方が、運用はずっと楽になる。

うちのチームが落ち着いた設計は、以下の3層構造。

flowchart TB
    A[Organization Root] --> B[SCP: 最小限のDeny]
    B --> C["✅ 危険なサービスは絶対に禁止"]
    B --> D["✅ 権限昇格のような操作は禁止"]
    
    A --> E[Account-level IAM]
    E --> F["✅ ロールベースの許可"]
    E --> G["✅ タグベースの条件付き許可"]
    
    A --> H[Service Control Policies]
    H --> I["Account SCP: より詳細な制御"]
    
    style B fill:#e1f5ff
    style E fill:#f3e5f5
    style H fill:#e8f5e9

この設計の肝は、SCPは「この組織内では絶対にこれはやらない」という最小限の制約に限定することだ。それ以外の権限制御はIAMで精密にやる。

実際に、うちが本当に必要だったSCPはこれくらい。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PreventIAMRootActions",
      "Effect": "Deny",
      "Action": [
        "iam:DeleteUser",
        "iam:DeleteRole",
        "iam:PutUserPolicy",
        "iam:AttachUserPolicy",
        "iam:CreateAccessKey"
      ],
      "Resource": [
        "arn:aws:iam::*:role/RootRole",
        "arn:aws:iam::*:user/root"
      ]
    },
    {
      "Sid": "PreventDisablingCloudTrail",
      "Effect": "Deny",
      "Action": [
        "cloudtrail:StopLogging",
        "cloudtrail:DeleteTrail"
      ],
      "Resource": "*"
    },
    {
      "Sid": "PreventSCPBypass",
      "Effect": "Deny",
      "Action": [
        "organizations:DetachPolicy",
        "organizations:DeletePolicy"
      ],
      "Resource": "*"
    }
  ]
}

「最小限」という表現が大事なんだよ。本当に「誰が何をしてもやってはいけない」ことだけを書いた。開発環境でのEC2削除制限とか、そういう「運用的なガイドライン」的なものはSCPには書かない。それはIAMロールで実装する方が、よっぽど融通がきく。

AWSの構成図で見たSCP実装アーキテクチャ

うちのマルチアカウント環境では、こんな構成でSCPを運用してる。

graph TB
    subgraph Organization["AWS Organization"]
        Root["Organization Root<br/>SCPs: Baseline"]
        Root --> OU_Dev["OU: Development<br/>SCP: Dev制限"]
        Root --> OU_Prod["OU: Production<br/>SCP: Prod制限"]
        Root --> OU_Sec["OU: Security<br/>SCP: 最小限制"]
        
        OU_Dev --> DevAcc1["Dev Account A<br/>IAM: 自由度高"]
        OU_Dev --> DevAcc2["Dev Account B<br/>IAM: 自由度高"]
        
        OU_Prod --> ProdAcc["Prod Account<br/>IAM: 厳格"]
        
        OU_Sec --> SecAcc["Security Account<br/>IAM: 最小権限"]
    end
    
    Root -->|CloudTrail Logs| CloudTrail[("CloudTrail<br/>Central Logging")]
    Root -->|SCP Events| CloudTrail
    
    ProdAcc -->|IAM: Deny<br/>iam:DeleteObject| S3[("Prod S3")]
    ProdAcc -->|IAM: Deny<br/>ec2:TerminateInstances| EC2[("Prod EC2")]
    
    DevAcc1 -->|IAM: Allow<br/>ec2:*| DevEC2[("Dev EC2")]
    DevAcc1 -->|SCP Deny over-ride| DevEC2
    
    style Root fill:#fff3cd
    style OU_Prod fill:#f8d7da
    style OU_Dev fill:#d1ecf1
    style OU_Sec fill:#d4edda

この構成のポイントを挙げるとこんな感じだ。

Organization Rootには「絶対に守りたいこと」だけをSCPで書く。CloudTrail停止、SCP削除、root権限乱用の防止。ここが守られなければ、下位の全てが意味を失う。

OU別のSCPでは、環境特性に応じた制約を追加。Prodでは「本番データの削除を強く制限」する。Devでは「実験を促進するため最小限」に留める。環境によって必要な制約が違うんだよ。

Account-level IAMで個別のロール管理。SCPは「ガードレール」だから、IAMで「誰が、どのロールで、どこまで」を細かく定義する。この組み合わせが大事。

CloudTrail集約で、SCP由来とIAM由来の拒否を両方ログに記録。あとから分析できるようにしておく。

CloudTrailでSCP効果を実測する

SCPを運用するなら、CloudTrailでちゃんと効いてるか検証する仕組みが必須だ。うちが半年かけて構築したのがこれ。

# SCP効果を検証するLambda関数
import json
import boto3
from datetime import datetime, timedelta

athena = boto3.client('athena')

def check_scp_effectiveness():
    # 過去7日間のCloudTrailから
    # SCPで拒否された操作をカウント
    
    query = """
    SELECT
        userIdentity.principalId,
        eventName,
        COUNT(*) as deny_count,
        sourceIPAddress
    FROM cloudtrail_logs
    WHERE eventTime >= date_format(current_timestamp - interval '7' day, '%Y-%m-%dT%H:%i:%sZ')
        AND errorCode = 'AccessDenied'
        AND requestParameters LIKE '%TerminateInstances%'
    GROUP BY userIdentity.principalId, eventName, sourceIPAddress
    ORDER BY deny_count DESC
    """
    
    response = athena.start_query_execution(
        QueryString=query,
        QueryExecutionContext={'Database': 'cloudtrail'},
        ResultConfiguration={'OutputLocation': 's3://query-results/'}
    )
    
    return response

# 実行結果から、本当にSCPで止まってるのか
# IAMで止まってるのかを判定
def differentiate_deny_source(cloudtrail_event):
    """
    CloudTrailログからSCP由来とIAM由来のDenyを判定
    """
    
    event = json.loads(cloudtrail_event)
    
    # SCP由来の場合、errorCode は AccessDenied
    # だが、errorMessage には特定の文言が含まれない可能性がある
    if event.get('errorCode') == 'AccessDenied':
        error_msg = event.get('errorMessage', '')
        
        # IAM的なエラー文言
        if 'not authorized' in error_msg:
            return 'IAM_DENY'
        
        # SCP的なエラー(CloudTrail単独では区別が難しい)
        # Organizations API経由でSCP確認するしかない
        return 'POTENTIALLY_SCP'
    
    return 'UNKNOWN'

実際に使ってみて気づいたのは、CloudTrailだけからはSCP由来とIAM由来の区別がほぼできないってことだ。結局、Organizations APIで「このアカウントに付いてるSCPは?」って確認するしかない。

organizations = boto3.client('organizations')

def get_effective_policy(account_id):
    """
    あるアカウントに実際に効いてるSCPを全部取得
    """
    
    policies = []
    
    # 親OUから順に遡る
    parent = organizations.list_parents(ChildId=account_id)
    parent_id = parent['Parents'][0]['Id']
    
    # OU階層の上まで遡る
    while parent_id:
        ou_policies = organizations.list_policies_for_target(
            TargetId=parent_id,
            Filter='SERVICE_CONTROL_POLICY'
        )
        
        for policy_id in ou_policies['Policies']:
            policy_detail = organizations.describe_policy(
                PolicyId=policy_id['Id']
            )
            policies.append(policy_detail['Policy'])
        
        # 親をたどる
        try:
            parent = organizations.list_parents(ChildId=parent_id)
            parent_id = parent['Parents'][0]['Id']
        except:
            break
    
    return policies

これを定期的に実行して、「本当はこのSCPが効くはずだけど、実際には許可されてる操作」みたいな異常を検出する。地味だけど、この検証がないと「本当に機能してるのか」って不安が消えない。

ベストプラクティス:最初の判断基準

1年半運用してきた経験から、SCPを書く前に絶対確認する3つのポイントがある。

1. 「本当にSCPでいいのか?」を問い直す

SCPは強力だけど、その分難しい。同じ目的なら、以下の優先順位で検討する。

  • IAMポリシー・権限境界 — 個別ユーザー・ロール単位で制御できるなら、まずこれを選ぶ。粒度が細かく、デバッグが簡単だ。
  • リソースベースポリシー — S3バケットポリシーやKMS等、リソース側で制御できるなら活用する。
  • SCP — 本当に「全Organization」「全アカウント」で統一したい制約のみ。これが最後の手段。

うちが失敗した例を挙げると、「Prod環境でのEC2削除を禁止したい」って要件に対してSCPで全アカウント制限してしまったんだ。実は本番環境のアカウントだけ制限すれば良かったのに。後からIAM権限境界に変更したよ。

2. SCPは段階的に導入

一度に複数のSCPを導入すると、何が原因で失敗しているのか、追跡不可能になる。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Phase1_CriticalServices",
      "Comment": "本当に必須の制約。2026年Q3に導入",
      "Effect": "Deny",
      "Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail"],
      "Resource": "*"
    },
    {
      "Sid": "Phase2_RootProtection",
      "Comment": "Phase1安定後。2026年Q4実装予定",
      "Effect": "Deny",
      "Action": ["iam:CreateAccessKey"],
      "Resource": "arn:aws:iam::*:user/root"
    }
  ]
}

こんな感じで、コメントに導入時期を明記しておく。バージョン管理もしておくと、「あのSCPいつ入ったっけ?」って状況が避けられる。

3. 例外は明示的に許可リスト化

SCPはDeny中心なので、「このアカウントだけは例外」みたいなケースが必ず出てくるんだ。その場合は、Organizations APIで明示的に上書きできる仕組みを作るのが吉。

# 例外管理用DynamoDB
class SCPExceptionManager:
    def add_exception(self, account_id, action, reason, expiry_date):
        """
        SCPの例外を明示的に記録
        - 例外理由
        - 有効期限
        - 承認者
        """
        exceptions_table.put_item(
            Item={
                'account_id': account_id,
                'action': action,
                'reason': reason,
                'expires': expiry_date,
                'created_at': datetime.now().isoformat(),
                'approved_by': 'security-team@company.com'
            }
        )
    
    def check_exception(self, account_id, action):
        """
        操作時に例外があるかチェック
        """
        response = exceptions_table.get_item(
            Key={'account_id': account_id, 'action': action}
        )
        
        if 'Item' in response:
            item = response['Item']
            if item['expires'] > datetime.now():
                return True  # 例外が有効
        
        return False

例外があると知っているのと知らないのでは、運用の安心感が全然違う。「なぜこの操作が許可されてるのか」が明確に分かる仕組みって大事なんだよ。

運用で気づいたこと:SCP + IAM + Organizations API の三角関係

9ヶ月目あたりで、「あ、複数のSCPが同時に効くんだ」って理解した。これが地味に重い。

Organization Root(全アカウント)にSCPがあって、かつ個別OUにもSCPがあると、両方が「AND」で効く。

Root SCP: Deny ec2:TerminateInstances
  AND
OU SCP: Deny ec2:DeleteSnapshot
  =
結果: ec2:TerminateInstances, ec2:DeleteSnapshot の両方が拒否される

これ自体は理にかなってるけど、本番で複数人が複数のSCPを管理してると、「あ、上位OUのSCPを見落としてた」みたいなバグが起きる。

だから、SCPは一箇所で集約管理するのがおすすめだ。うちは専用のCDKスタック作った。

// lib/scp-stack.ts
export class SCPManagementStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Organization Root SCP
    const rootPolicy = new scp.Policy(this, 'RootPolicy', {
      content: scp.PolicyDocument.fromFile('policies/root-baseline.json'),
      name: 'root-baseline',
      description: 'Critical controls for entire organization',
    });

    // OU-specific SCPs
    const prodOUPolicy = new scp.Policy(this, 'ProdOUPolicy', {
      content: scp.PolicyDocument.fromFile('policies/prod-ou.json'),
      name: 'prod-ou-controls',
      description: 'Production environment restrictions',
    });

    // Tag-based SCPs (2026年新機能)
    const taggedPolicy = new scp.Policy(this, 'TaggedPolicy', {
      content: new scp.PolicyDocument({
        statements: [
          new scp.PolicyStatement({
            effect: scp.Effect.DENY,
            actions: ['ec2:TerminateInstances'],
            resources: ['*'],
            conditions: {
              StringEquals: {
                'aws:ResourceTag/Environment': 'production'
              }
            }
          })
        ]
      }),
      name: 'tag-based-controls',
    });
  }
}

IaCで管理することで、「どのOUにどのSCPが付いてるか」が一目瞭然。変更履歴もGitに残る。後から「なぜこのSCPが入ったのか」って経緯を追跡できるのは、地味に便利だ。

まとめ

正直に言うと、SCP運用で本当に大事なことってシンプルなんだ。

SCPとIAMの役割分担を明確にする — SCPは最小限のガードレール。細かい権限制御はIAM。この区別がつかないと、本当にカオスになる。

CloudTrail + Organizations APIで可視化する — 「これ、本当に効いてるの?」という不安は、ログと検証で払拭する。効果測定なしにSCPは語れない。

段階的導入と明示的な例外管理 — 一度に全部は入れない。必ず例外は出てくる。その例外を仕組みで管理する。

IaCで集約管理する — 複数人が管理するとカオス。CDKやTerraformで一元化する。誰がいつ何を変えたか、履歴が残る。

本当に必要なのか、毎回問い直す — SCPは強力だけど複雑。IAMポリシーやリソースベースポリシーで実現できないか、常に検討する癖をつける。

正直、SCP導入のハードルは思った以上に高い。でも、マルチアカウント環境では「ガードレール」の発想は本当に大事。ただし、教科書通りでは絶対動かない。実務で試行錯誤するしかない。

皆さんのチームでSCP導入の際は、この失敗と教訓が少しでも役に立てばと思う。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事